commit d9c03814390e5b91c6935359fbfbcc68635bd75a
Author: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun Feb 1 19:56:04 2026 +0530
Initial commit: NuvioTV as clean branch
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..aa724b77
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 00000000..26d33521
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/.name b/.idea/.name
new file mode 100644
index 00000000..b3405b3b
--- /dev/null
+++ b/.idea/.name
@@ -0,0 +1 @@
+My Application
\ No newline at end of file
diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml
new file mode 100644
index 00000000..4a53bee8
--- /dev/null
+++ b/.idea/AndroidProjectSystem.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
new file mode 100644
index 00000000..b86273d9
--- /dev/null
+++ b/.idea/compiler.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml
new file mode 100644
index 00000000..4ea72a91
--- /dev/null
+++ b/.idea/copilot.data.migration.agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml
new file mode 100644
index 00000000..7ef04e2e
--- /dev/null
+++ b/.idea/copilot.data.migration.ask.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml
new file mode 100644
index 00000000..1f2ea11e
--- /dev/null
+++ b/.idea/copilot.data.migration.ask2agent.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml
new file mode 100644
index 00000000..8648f940
--- /dev/null
+++ b/.idea/copilot.data.migration.edit.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml
new file mode 100644
index 00000000..0d3beb0f
--- /dev/null
+++ b/.idea/deploymentTargetSelector.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 00000000..91f95584
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 00000000..639c779c
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 00000000..f0c6ad08
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/migrations.xml b/.idea/migrations.xml
new file mode 100644
index 00000000..f8051a6f
--- /dev/null
+++ b/.idea/migrations.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 00000000..b2c751a3
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml
new file mode 100644
index 00000000..16660f1d
--- /dev/null
+++ b/.idea/runConfigurations.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..496c564a
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.kotlin/errors/errors-1769668942713.log b/.kotlin/errors/errors-1769668942713.log
new file mode 100644
index 00000000..1219b509
--- /dev/null
+++ b/.kotlin/errors/errors-1769668942713.log
@@ -0,0 +1,4 @@
+kotlin version: 2.0.21
+error message: The daemon has terminated unexpectedly on startup attempt #1 with error code: 0. The daemon process output:
+ 1. Kotlin compile daemon is ready
+
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 00000000..42afabfd
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 00000000..590ad89d
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,115 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.hilt)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "com.nuvio.tv"
+ compileSdk {
+ version = release(36)
+ }
+
+ defaultConfig {
+ applicationId = "com.nuvio.tv"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
+ }
+ }
+ buildFeatures {
+ compose = true
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.ui.graphics)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.foundation:foundation")
+ implementation("androidx.compose.material:material-icons-extended")
+ implementation(libs.androidx.tv.foundation)
+ implementation(libs.androidx.tv.material)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.activity.compose)
+
+ // Hilt
+ implementation(libs.hilt.android)
+ ksp(libs.hilt.compiler)
+ implementation(libs.hilt.navigation.compose)
+
+ // Networking
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.moshi)
+ implementation(libs.okhttp)
+ implementation(libs.okhttp.logging)
+ implementation(libs.moshi)
+ ksp(libs.moshi.codegen)
+
+ // Coroutines
+ implementation(libs.coroutines.core)
+ implementation(libs.coroutines.android)
+
+ // Image Loading
+ implementation(libs.coil.compose)
+ implementation(libs.coil.svg)
+
+ // Navigation
+ implementation(libs.navigation.compose)
+
+ // DataStore
+ implementation(libs.datastore.preferences)
+
+ // ViewModel
+ implementation(libs.lifecycle.viewmodel.compose)
+
+ // Media3 ExoPlayer
+ implementation(libs.media3.exoplayer)
+ implementation(libs.media3.exoplayer.hls)
+ implementation(libs.media3.exoplayer.dash)
+ implementation(libs.media3.ui)
+ implementation(libs.media3.session)
+ implementation(libs.media3.common)
+
+ // Media3 FFmpeg Decoder Extension (locally built AAR)
+ implementation(files("libs/media3-decoder-ffmpeg.aar"))
+
+ // Local Plugin System
+ implementation(libs.quickjs.kt)
+ implementation(libs.jsoup)
+ implementation(libs.gson)
+
+ // Bundle real crypto-js (JS) for QuickJS plugins
+ implementation("org.webjars.npm:crypto-js:4.2.0")
+
+ androidTestImplementation(platform(libs.androidx.compose.bom))
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ debugImplementation(libs.androidx.compose.ui.tooling)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+}
\ No newline at end of file
diff --git a/app/libs/media3-decoder-ffmpeg.aar b/app/libs/media3-decoder-ffmpeg.aar
new file mode 100644
index 00000000..9563e508
Binary files /dev/null and b/app/libs/media3-decoder-ffmpeg.aar differ
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 00000000..481bb434
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..1634baaf
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/nuvio/tv/MainActivity.kt b/app/src/main/java/com/nuvio/tv/MainActivity.kt
new file mode 100644
index 00000000..0c7b75e2
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/MainActivity.kt
@@ -0,0 +1,179 @@
+package com.nuvio.tv
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.Image
+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.fillMaxWidth
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.background
+import androidx.compose.foundation.selection.selectableGroup
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.res.painterResource
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.unit.dp
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.DrawerValue
+import androidx.tv.material3.Icon
+import androidx.tv.material3.NavigationDrawer
+import androidx.tv.material3.NavigationDrawerItem
+import androidx.tv.material3.Text
+import androidx.tv.material3.rememberDrawerState
+import androidx.tv.material3.Surface
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Extension
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Bookmark
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Settings
+import coil.compose.AsyncImage
+import com.nuvio.tv.ui.navigation.NuvioNavHost
+import com.nuvio.tv.ui.navigation.Screen
+import com.nuvio.tv.ui.theme.NuvioTheme
+import dagger.hilt.android.AndroidEntryPoint
+
+@AndroidEntryPoint
+class MainActivity : ComponentActivity() {
+ @OptIn(ExperimentalTvMaterial3Api::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ NuvioTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ shape = RectangleShape
+ ) {
+ val navController = rememberNavController()
+ val navBackStackEntry by navController.currentBackStackEntryAsState()
+ val currentRoute = navBackStackEntry?.destination?.route
+ val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
+
+ val rootRoutes = setOf(
+ Screen.Home.route,
+ Screen.Search.route,
+ Screen.Library.route,
+ Screen.Settings.route,
+ Screen.AddonManager.route
+ )
+
+ LaunchedEffect(currentRoute) {
+ if (currentRoute in rootRoutes) {
+ drawerState.setValue(DrawerValue.Closed)
+ } else {
+ drawerState.setValue(DrawerValue.Closed)
+ }
+ }
+
+ BackHandler(enabled = currentRoute in rootRoutes && drawerState.currentValue == DrawerValue.Closed) {
+ drawerState.setValue(DrawerValue.Open)
+ }
+
+ val drawerItems = listOf(
+ Screen.Home.route to ("Home" to Icons.Filled.Home),
+ Screen.Search.route to ("Search" to Icons.Filled.Search),
+ Screen.Library.route to ("Library" to Icons.Filled.Bookmark),
+ Screen.AddonManager.route to ("Addons" to Icons.Filled.Extension),
+ Screen.Settings.route to ("Settings" to Icons.Filled.Settings)
+ )
+
+ val showSidebar = currentRoute in rootRoutes
+ if (showSidebar) {
+ NavigationDrawer(
+ drawerState = drawerState,
+ drawerContent = { drawerValue ->
+ val drawerWidth = if (drawerValue == DrawerValue.Open) 260.dp else 72.dp
+ Column(
+ modifier = Modifier
+ .fillMaxHeight()
+ .width(drawerWidth)
+ .background(
+ Brush.horizontalGradient(
+ colors = listOf(
+ Color.Black.copy(alpha = 0.7f),
+ Color.Black.copy(alpha = 0.35f),
+ Color.Transparent
+ )
+ )
+ )
+ .padding(12.dp)
+ .selectableGroup(),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ if (drawerValue == DrawerValue.Open) {
+ Image(
+ painter = painterResource(id = R.drawable.nuvio_text),
+ contentDescription = "Nuvio",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ contentScale = ContentScale.Fit
+ )
+ } else {
+ Image(
+ painter = painterResource(id = R.drawable.nuvio_n),
+ contentDescription = "Nuvio",
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(48.dp),
+ contentScale = ContentScale.Fit
+ )
+ }
+ drawerItems.forEach { (route, item) ->
+ val (label, icon) = item
+ NavigationDrawerItem(
+ selected = currentRoute == route,
+ onClick = {
+ if (currentRoute != route) {
+ navController.navigate(route) {
+ popUpTo(navController.graph.startDestinationId) {
+ saveState = true
+ }
+ launchSingleTop = true
+ restoreState = true
+ }
+ }
+ drawerState.setValue(DrawerValue.Closed)
+ },
+ leadingContent = {
+ Icon(imageVector = icon, contentDescription = null)
+ }
+ ) {
+ if (drawerValue == DrawerValue.Open) {
+ Text(label)
+ }
+ }
+ }
+ }
+ }
+ ) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ NuvioNavHost(navController = navController)
+ }
+ }
+ } else {
+ Box(modifier = Modifier.fillMaxSize()) {
+ NuvioNavHost(navController = navController)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/NuvioApplication.kt b/app/src/main/java/com/nuvio/tv/NuvioApplication.kt
new file mode 100644
index 00000000..4e01c5f0
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/NuvioApplication.kt
@@ -0,0 +1,7 @@
+package com.nuvio.tv
+
+import android.app.Application
+import dagger.hilt.android.HiltAndroidApp
+
+@HiltAndroidApp
+class NuvioApplication : Application()
diff --git a/app/src/main/java/com/nuvio/tv/core/di/NetworkModule.kt b/app/src/main/java/com/nuvio/tv/core/di/NetworkModule.kt
new file mode 100644
index 00000000..56c06ad9
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/di/NetworkModule.kt
@@ -0,0 +1,67 @@
+package com.nuvio.tv.core.di
+
+import com.nuvio.tv.data.remote.api.AddonApi
+import com.nuvio.tv.data.remote.api.TmdbApi
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.moshi.MoshiConverterFactory
+import java.util.concurrent.TimeUnit
+import javax.inject.Named
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object NetworkModule {
+
+ @Provides
+ @Singleton
+ fun provideMoshi(): Moshi = Moshi.Builder()
+ .add(KotlinJsonAdapterFactory())
+ .build()
+
+ @Provides
+ @Singleton
+ fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .addInterceptor(HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BODY
+ })
+ .build()
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit =
+ Retrofit.Builder()
+ .baseUrl("https://placeholder.nuvio.tv/")
+ .client(okHttpClient)
+ .addConverterFactory(MoshiConverterFactory.create(moshi))
+ .build()
+
+ @Provides
+ @Singleton
+ @Named("tmdb")
+ fun provideTmdbRetrofit(okHttpClient: OkHttpClient, moshi: Moshi): Retrofit =
+ Retrofit.Builder()
+ .baseUrl("https://api.themoviedb.org/3/")
+ .client(okHttpClient)
+ .addConverterFactory(MoshiConverterFactory.create(moshi))
+ .build()
+
+ @Provides
+ @Singleton
+ fun provideAddonApi(retrofit: Retrofit): AddonApi =
+ retrofit.create(AddonApi::class.java)
+
+ @Provides
+ @Singleton
+ fun provideTmdbApi(@Named("tmdb") retrofit: Retrofit): TmdbApi =
+ retrofit.create(TmdbApi::class.java)
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/di/RepositoryModule.kt b/app/src/main/java/com/nuvio/tv/core/di/RepositoryModule.kt
new file mode 100644
index 00000000..3b7edfde
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/di/RepositoryModule.kt
@@ -0,0 +1,42 @@
+package com.nuvio.tv.core.di
+
+import com.nuvio.tv.data.repository.AddonRepositoryImpl
+import com.nuvio.tv.data.repository.CatalogRepositoryImpl
+import com.nuvio.tv.data.repository.MetaRepositoryImpl
+import com.nuvio.tv.data.repository.StreamRepositoryImpl
+import com.nuvio.tv.data.repository.WatchProgressRepositoryImpl
+import com.nuvio.tv.domain.repository.AddonRepository
+import com.nuvio.tv.domain.repository.CatalogRepository
+import com.nuvio.tv.domain.repository.MetaRepository
+import com.nuvio.tv.domain.repository.StreamRepository
+import com.nuvio.tv.domain.repository.WatchProgressRepository
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+abstract class RepositoryModule {
+
+ @Binds
+ @Singleton
+ abstract fun bindAddonRepository(impl: AddonRepositoryImpl): AddonRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindCatalogRepository(impl: CatalogRepositoryImpl): CatalogRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindMetaRepository(impl: MetaRepositoryImpl): MetaRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindStreamRepository(impl: StreamRepositoryImpl): StreamRepository
+
+ @Binds
+ @Singleton
+ abstract fun bindWatchProgressRepository(impl: WatchProgressRepositoryImpl): WatchProgressRepository
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/network/NetworkResult.kt b/app/src/main/java/com/nuvio/tv/core/network/NetworkResult.kt
new file mode 100644
index 00000000..cb64dcfa
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/network/NetworkResult.kt
@@ -0,0 +1,7 @@
+package com.nuvio.tv.core.network
+
+sealed class NetworkResult {
+ data class Success(val data: T) : NetworkResult()
+ data class Error(val message: String, val code: Int? = null) : NetworkResult()
+ data object Loading : NetworkResult()
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/network/SafeApiCall.kt b/app/src/main/java/com/nuvio/tv/core/network/SafeApiCall.kt
new file mode 100644
index 00000000..c91c696c
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/network/SafeApiCall.kt
@@ -0,0 +1,18 @@
+package com.nuvio.tv.core.network
+
+import retrofit2.Response
+
+suspend fun safeApiCall(apiCall: suspend () -> Response): NetworkResult {
+ return try {
+ val response = apiCall()
+ if (response.isSuccessful) {
+ response.body()?.let {
+ NetworkResult.Success(it)
+ } ?: NetworkResult.Error("Empty response body")
+ } else {
+ NetworkResult.Error(response.message(), response.code())
+ }
+ } catch (e: Exception) {
+ NetworkResult.Error(e.message ?: "Unknown error occurred")
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt b/app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt
new file mode 100644
index 00000000..bc92bfa7
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt
@@ -0,0 +1,483 @@
+package com.nuvio.tv.core.plugin
+
+import android.util.Log
+import com.nuvio.tv.data.local.PluginDataStore
+import com.nuvio.tv.domain.model.LocalScraperResult
+import com.nuvio.tv.domain.model.PluginManifest
+import com.nuvio.tv.domain.model.PluginRepository
+import com.nuvio.tv.domain.model.ScraperInfo
+import com.nuvio.tv.domain.model.ScraperManifestInfo
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Semaphore
+import kotlinx.coroutines.sync.withPermit
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.security.MessageDigest
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val TAG = "PluginManager"
+private const val MAX_CONCURRENT_SCRAPERS = 5
+private const val MAX_RESULT_ITEMS = 150
+private const val MAX_RESPONSE_SIZE = 5 * 1024 * 1024L
+
+@Singleton
+class PluginManager @Inject constructor(
+ private val dataStore: PluginDataStore,
+ private val runtime: PluginRuntime
+) {
+ private val moshi = Moshi.Builder()
+ .addLast(KotlinJsonAdapterFactory())
+ .build()
+
+ private val manifestAdapter = moshi.adapter(PluginManifest::class.java)
+
+ private val httpClient = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .build()
+
+ private fun sha256Hex(text: String): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8))
+ val sb = StringBuilder(digest.size * 2)
+ for (b in digest) {
+ sb.append(((b.toInt() shr 4) and 0xF).toString(16))
+ sb.append((b.toInt() and 0xF).toString(16))
+ }
+ return sb.toString()
+ }
+
+ // Single-flight map to prevent duplicate scraper executions
+ private val inFlightScrapers = ConcurrentHashMap>>()
+
+ // Semaphore to limit concurrent scrapers
+ private val scraperSemaphore = Semaphore(MAX_CONCURRENT_SCRAPERS)
+
+ // Flow of all repositories
+ val repositories: Flow> = dataStore.repositories
+
+ // Flow of all scrapers
+ val scrapers: Flow> = dataStore.scrapers
+
+ // Flow of plugins enabled state
+ val pluginsEnabled: Flow = dataStore.pluginsEnabled
+
+ // Combined flow of enabled scrapers
+ val enabledScrapers: Flow> = combine(
+ scrapers,
+ pluginsEnabled
+ ) { scraperList, enabled ->
+ if (enabled) scraperList.filter { it.enabled } else emptyList()
+ }
+
+ /**
+ * Add a new repository from manifest URL
+ */
+ suspend fun addRepository(manifestUrl: String): Result = withContext(Dispatchers.IO) {
+ try {
+ Log.d(TAG, "Adding repository from: $manifestUrl")
+
+ // Fetch manifest
+ val manifest = fetchManifest(manifestUrl)
+ ?: return@withContext Result.failure(Exception("Failed to fetch manifest"))
+
+ // Create repository
+ val repo = PluginRepository(
+ id = UUID.randomUUID().toString(),
+ name = manifest.name,
+ url = manifestUrl,
+ enabled = true,
+ lastUpdated = System.currentTimeMillis(),
+ scraperCount = manifest.scrapers.size
+ )
+
+ // Save repository
+ dataStore.addRepository(repo)
+
+ // Download and save scrapers
+ downloadScrapers(repo.id, manifestUrl, manifest.scrapers)
+
+ Log.d(TAG, "Repository added: ${repo.name} with ${manifest.scrapers.size} scrapers")
+ Result.success(repo)
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to add repository: ${e.message}", e)
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Remove a repository and its scrapers
+ */
+ suspend fun removeRepository(repoId: String) {
+ val scraperList = dataStore.scrapers.first()
+
+ // Remove all scrapers from this repo
+ scraperList.filter { it.repositoryId == repoId }.forEach { scraper ->
+ dataStore.deleteScraperCode(scraper.id)
+ }
+
+ // Remove scrapers from list
+ val updatedScrapers = scraperList.filter { it.repositoryId != repoId }
+ dataStore.saveScrapers(updatedScrapers)
+
+ // Remove repository
+ dataStore.removeRepository(repoId)
+ }
+
+ /**
+ * Refresh a repository - re-download manifest and scrapers
+ */
+ suspend fun refreshRepository(repoId: String): Result = withContext(Dispatchers.IO) {
+ try {
+ val repo = dataStore.repositories.first().find { it.id == repoId }
+ ?: return@withContext Result.failure(Exception("Repository not found"))
+
+ val manifest = fetchManifest(repo.url)
+ ?: return@withContext Result.failure(Exception("Failed to fetch manifest"))
+
+ // Update repository
+ val updatedRepo = repo.copy(
+ name = manifest.name,
+ lastUpdated = System.currentTimeMillis(),
+ scraperCount = manifest.scrapers.size
+ )
+ dataStore.updateRepository(updatedRepo)
+
+ // Re-download scrapers
+ downloadScrapers(repo.id, repo.url, manifest.scrapers)
+
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to refresh repository: ${e.message}", e)
+ Result.failure(e)
+ }
+ }
+
+ /**
+ * Toggle scraper enabled state
+ */
+ suspend fun toggleScraper(scraperId: String, enabled: Boolean) {
+ val scraperList = dataStore.scrapers.first()
+ val updatedScrapers = scraperList.map { scraper ->
+ if (scraper.id == scraperId) scraper.copy(enabled = enabled) else scraper
+ }
+ dataStore.saveScrapers(updatedScrapers)
+ }
+
+ /**
+ * Toggle plugins globally enabled
+ */
+ suspend fun setPluginsEnabled(enabled: Boolean) {
+ dataStore.setPluginsEnabled(enabled)
+ }
+
+ /**
+ * Execute all enabled scrapers for a given media
+ */
+ suspend fun executeScrapers(
+ tmdbId: String,
+ mediaType: String,
+ season: Int? = null,
+ episode: Int? = null
+ ): List = coroutineScope {
+ if (!dataStore.pluginsEnabled.first()) {
+ return@coroutineScope emptyList()
+ }
+
+ val enabledScraperList = enabledScrapers.first()
+ .filter { it.supportsType(mediaType) }
+
+ if (enabledScraperList.isEmpty()) {
+ return@coroutineScope emptyList()
+ }
+
+ Log.d(TAG, "Executing ${enabledScraperList.size} scrapers for $mediaType:$tmdbId")
+
+ val results = enabledScraperList.map { scraper ->
+ async {
+ executeScraperWithSingleFlight(scraper, tmdbId, mediaType, season, episode)
+ }
+ }.awaitAll()
+
+ results.flatten()
+ .distinctBy { it.url }
+ .take(MAX_RESULT_ITEMS)
+ }
+
+ /**
+ * Execute all enabled scrapers and emit results as each scraper completes.
+ * Returns a Flow that emits (scraperName, results) pairs.
+ */
+ fun executeScrapersStreaming(
+ tmdbId: String,
+ mediaType: String,
+ season: Int? = null,
+ episode: Int? = null
+ ): Flow>> = channelFlow {
+ val enabledList = enabledScrapers.first()
+ .filter { it.supportsType(mediaType) }
+
+ if (enabledList.isEmpty() || !dataStore.pluginsEnabled.first()) {
+ return@channelFlow
+ }
+
+ Log.d(TAG, "Streaming execution of ${enabledList.size} scrapers for $mediaType:$tmdbId")
+
+ // Launch all scrapers concurrently within the channelFlow scope
+ enabledList.forEach { scraper ->
+ launch {
+ try {
+ val results = executeScraperWithSingleFlight(scraper, tmdbId, mediaType, season, episode)
+ if (results.isNotEmpty()) {
+ send(scraper.name to results)
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Scraper ${scraper.name} failed in streaming: ${e.message}")
+ }
+ }
+ }
+ }
+
+ /**
+ * Execute a single scraper with single-flight deduplication
+ */
+ private suspend fun executeScraperWithSingleFlight(
+ scraper: ScraperInfo,
+ tmdbId: String,
+ mediaType: String,
+ season: Int?,
+ episode: Int?
+ ): List {
+ val cacheKey = "${scraper.id}:$tmdbId:$mediaType:$season:$episode"
+
+ // Check if already in flight
+ val existing = inFlightScrapers[cacheKey]
+ if (existing != null) {
+ return try {
+ existing.await()
+ } catch (e: Exception) {
+ emptyList()
+ }
+ }
+
+ // Create new deferred
+ return coroutineScope {
+ val deferred = async {
+ scraperSemaphore.withPermit {
+ executeScraper(scraper, tmdbId, mediaType, season, episode)
+ }
+ }
+
+ inFlightScrapers[cacheKey] = deferred
+
+ try {
+ deferred.await()
+ } catch (e: Exception) {
+ Log.e(TAG, "Scraper ${scraper.name} failed: ${e.message}")
+ emptyList()
+ } finally {
+ inFlightScrapers.remove(cacheKey)
+ }
+ }
+ }
+
+ /**
+ * Execute a single scraper
+ */
+ suspend fun executeScraper(
+ scraper: ScraperInfo,
+ tmdbId: String,
+ mediaType: String,
+ season: Int?,
+ episode: Int?
+ ): List {
+ return try {
+ val code = dataStore.getScraperCode(scraper.id)
+ if (code.isNullOrBlank()) {
+ Log.w(TAG, "No code found for scraper: ${scraper.name}")
+ return emptyList()
+ }
+
+ // Debug: confirm which exact JS code is running on-device.
+ try {
+ val sha = sha256Hex(code)
+ val bytes = code.toByteArray(Charsets.UTF_8).size
+ val hasHrefliLogs = code.contains("[UHDMovies][Hrefli]", ignoreCase = false) ||
+ code.contains("[Hrefli]", ignoreCase = false)
+ Log.d(
+ TAG,
+ "Scraper code loaded: ${scraper.name}(${scraper.id}) bytes=$bytes sha256=${sha.take(12)} hrefliLogs=$hasHrefliLogs"
+ )
+ } catch (_: Exception) {
+ // ignore
+ }
+
+ val settings = dataStore.getScraperSettings(scraper.id)
+
+ Log.d(TAG, "Executing scraper: ${scraper.name}")
+ val results = runtime.executePlugin(
+ code = code,
+ tmdbId = tmdbId,
+ mediaType = mediaType,
+ season = season,
+ episode = episode,
+ scraperId = scraper.id,
+ scraperSettings = settings
+ )
+
+ Log.d(TAG, "Scraper ${scraper.name} returned ${results.size} results")
+ results.map { it.copy(provider = scraper.name) }
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to execute scraper ${scraper.name}: ${e.message}", e)
+ emptyList()
+ }
+ }
+
+ /**
+ * Test a scraper with sample data
+ */
+ suspend fun testScraper(scraperId: String): Result> {
+ val scraper = dataStore.scrapers.first().find { it.id == scraperId }
+ ?: return Result.failure(Exception("Scraper not found"))
+
+ // Use a popular movie for testing (The Matrix - 603)
+ val testTmdbId = "603"
+ val testMediaType = if (scraper.supportsType("movie")) "movie" else "series"
+
+ return try {
+ val results = executeScraper(scraper, testTmdbId, testMediaType, 1, 1)
+ Result.success(results)
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ private suspend fun fetchManifest(url: String): PluginManifest? = withContext(Dispatchers.IO) {
+ try {
+ val request = Request.Builder()
+ .url(url)
+ .header("User-Agent", "NuvioTV/1.0")
+ .build()
+
+ val response = httpClient.newCall(request).execute()
+ if (!response.isSuccessful) {
+ Log.e(TAG, "Failed to fetch manifest: ${response.code}")
+ return@withContext null
+ }
+
+ val body = response.body?.string() ?: return@withContext null
+ manifestAdapter.fromJson(body)
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error fetching manifest: ${e.message}", e)
+ null
+ }
+ }
+
+ private suspend fun downloadScrapers(
+ repoId: String,
+ manifestUrl: String,
+ scraperInfos: List
+ ) = withContext(Dispatchers.IO) {
+ val baseUrl = manifestUrl.substringBeforeLast("/")
+ val existingScrapers = dataStore.scrapers.first().toMutableList()
+
+ scraperInfos.forEach { info ->
+ try {
+ val codeUrl = if (info.filename.startsWith("http")) {
+ info.filename
+ } else {
+ "$baseUrl/${info.filename}"
+ }
+
+ // Check response size before downloading
+ val headRequest = Request.Builder()
+ .url(codeUrl)
+ .head()
+ .build()
+
+ val headResponse = httpClient.newCall(headRequest).execute()
+ val contentLength = headResponse.header("Content-Length")?.toLongOrNull() ?: 0
+
+ if (contentLength > MAX_RESPONSE_SIZE) {
+ Log.w(TAG, "Scraper ${info.name} too large: $contentLength bytes")
+ return@forEach
+ }
+
+ // Download code
+ val codeRequest = Request.Builder()
+ .url(codeUrl)
+ .header("User-Agent", "NuvioTV/1.0")
+ .build()
+
+ val codeResponse = httpClient.newCall(codeRequest).execute()
+ if (!codeResponse.isSuccessful) {
+ Log.e(TAG, "Failed to download scraper ${info.name}: ${codeResponse.code}")
+ return@forEach
+ }
+
+ val code = codeResponse.body?.string() ?: return@forEach
+
+ try {
+ val sha = sha256Hex(code)
+ val hasHrefliLogs = code.contains("[UHDMovies][Hrefli]", ignoreCase = false) ||
+ code.contains("[Hrefli]", ignoreCase = false)
+ Log.d(
+ TAG,
+ "Downloaded scraper code: ${info.name}(${info.id}) bytes=${code.toByteArray(Charsets.UTF_8).size} sha256=${sha.take(12)} hrefliLogs=$hasHrefliLogs url=$codeUrl"
+ )
+ } catch (_: Exception) {
+ // ignore
+ }
+
+ // Create scraper info
+ val scraperId = "$repoId:${info.id}"
+ val scraper = ScraperInfo(
+ id = scraperId,
+ repositoryId = repoId,
+ name = info.name,
+ description = info.description ?: "",
+ version = info.version,
+ filename = info.filename,
+ supportedTypes = info.supportedTypes,
+ enabled = true,
+ manifestEnabled = info.enabled,
+ logo = info.logo,
+ contentLanguage = info.contentLanguage ?: emptyList(),
+ formats = info.formats
+ )
+
+ // Save code
+ dataStore.saveScraperCode(scraperId, code)
+
+ // Update scraper list
+ existingScrapers.removeAll { it.id == scraperId }
+ existingScrapers.add(scraper)
+
+ Log.d(TAG, "Downloaded scraper: ${info.name}")
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error downloading scraper ${info.name}: ${e.message}", e)
+ }
+ }
+
+ dataStore.saveScrapers(existingScrapers)
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/plugin/PluginRuntime.kt b/app/src/main/java/com/nuvio/tv/core/plugin/PluginRuntime.kt
new file mode 100644
index 00000000..23c08e4a
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/plugin/PluginRuntime.kt
@@ -0,0 +1,1029 @@
+package com.nuvio.tv.core.plugin
+
+import android.util.Log
+import com.dokar.quickjs.binding.asyncFunction
+import com.dokar.quickjs.binding.define
+import com.dokar.quickjs.binding.function
+import com.dokar.quickjs.quickJs
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.nuvio.tv.domain.model.LocalScraperResult
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import okhttp3.Headers
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.jsoup.Jsoup
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Element
+import org.jsoup.select.Elements
+import java.io.ByteArrayInputStream
+import java.net.URL
+import java.security.MessageDigest
+import java.util.Base64
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+import java.util.zip.GZIPInputStream
+import java.util.zip.InflaterInputStream
+import kotlin.text.Charsets
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val TAG = "PluginRuntime"
+private const val PLUGIN_TIMEOUT_MS = 60_000L
+
+@Singleton
+class PluginRuntime @Inject constructor() {
+
+ private val gson: Gson = GsonBuilder().create()
+
+ private val httpClient = OkHttpClient.Builder()
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .followRedirects(true)
+ .followSslRedirects(true)
+ .build()
+
+ // Store parsed documents for cheerio
+ private val documentCache = ConcurrentHashMap()
+ private val elementCache = ConcurrentHashMap()
+
+ // Pre-compiled regex for :contains() selector conversion
+ private val containsRegex = Regex(""":contains\(["']([^"']+)["']\)""")
+
+ @Volatile
+ private var cachedCryptoJsSource: String? = null
+
+ private fun loadCryptoJsSourceOrNull(): String? {
+ cachedCryptoJsSource?.let { return it }
+ val cl = this::class.java.classLoader ?: return null
+
+ // WebJars layout: META-INF/resources/webjars/crypto-js//...
+ val candidatePaths = listOf(
+ "META-INF/resources/webjars/crypto-js/4.2.0/crypto-js.min.js",
+ "META-INF/resources/webjars/crypto-js/4.2.0/crypto-js.js",
+ "META-INF/resources/webjars/crypto-js/4.2.0/crypto-js/crypto-js.min.js",
+ "META-INF/resources/webjars/crypto-js/4.2.0/crypto-js/crypto-js.js",
+ )
+
+ for (path in candidatePaths) {
+ try {
+ cl.getResourceAsStream(path)?.use { input ->
+ val text = input.readBytes().toString(Charsets.UTF_8)
+ cachedCryptoJsSource = text
+ return text
+ }
+ } catch (_: Exception) {
+ // Try next candidate
+ }
+ }
+ return null
+ }
+
+ private fun normalizeBase64(input: String): String {
+ var s = input.trim().replace("\n", "").replace("\r", "").replace(" ", "")
+ s = s.replace('-', '+').replace('_', '/')
+ val mod = s.length % 4
+ if (mod != 0) {
+ s += "=".repeat(4 - mod)
+ }
+ return s
+ }
+
+ private fun base64Decode(input: String): ByteArray {
+ return Base64.getDecoder().decode(normalizeBase64(input))
+ }
+
+ private fun base64Encode(bytes: ByteArray): String {
+ return Base64.getEncoder().encodeToString(bytes)
+ }
+
+ private fun bytesToHex(bytes: ByteArray): String {
+ val sb = StringBuilder(bytes.size * 2)
+ for (b in bytes) {
+ sb.append(((b.toInt() shr 4) and 0xF).toString(16))
+ sb.append((b.toInt() and 0xF).toString(16))
+ }
+ return sb.toString()
+ }
+
+ /**
+ * Execute a plugin and return streams
+ */
+ suspend fun executePlugin(
+ code: String,
+ tmdbId: String,
+ mediaType: String,
+ season: Int?,
+ episode: Int?,
+ scraperId: String,
+ scraperSettings: Map = emptyMap()
+ ): List = withContext(Dispatchers.IO) {
+ withTimeout(PLUGIN_TIMEOUT_MS) {
+ executePluginInternal(code, tmdbId, mediaType, season, episode, scraperId, scraperSettings)
+ }
+ }
+
+ private suspend fun executePluginInternal(
+ code: String,
+ tmdbId: String,
+ mediaType: String,
+ season: Int?,
+ episode: Int?,
+ scraperId: String,
+ scraperSettings: Map
+ ): List {
+ // Clear caches before execution
+ documentCache.clear()
+ elementCache.clear()
+
+ var resultJson = "[]"
+
+ try {
+ quickJs(Dispatchers.IO) {
+ // Define console object
+ define("console") {
+ function("log") { args ->
+ Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
+ }
+ function("error") { args ->
+ Log.e("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
+ }
+ function("warn") { args ->
+ Log.w("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
+ }
+ function("info") { args ->
+ Log.i("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
+ }
+ function("debug") { args ->
+ Log.d("Plugin:$scraperId", args.joinToString(" ") { it?.toString() ?: "null" })
+ }
+ }
+
+ // Define native fetch function (async)
+ asyncFunction("__native_fetch") { args ->
+ val url = args.getOrNull(0)?.toString() ?: ""
+ val method = args.getOrNull(1)?.toString() ?: "GET"
+ val headersJson = args.getOrNull(2)?.toString() ?: "{}"
+ val body = args.getOrNull(3)?.toString() ?: ""
+ performNativeFetch(url, method, headersJson, body)
+ }
+
+ // Define URL parser
+ function("__parse_url") { args ->
+ val urlString = args.getOrNull(0)?.toString() ?: ""
+ parseUrl(urlString)
+ }
+
+ // Define cheerio load function
+ function("__cheerio_load") { args ->
+ val html = args.getOrNull(0)?.toString() ?: ""
+ val docId = UUID.randomUUID().toString()
+ val doc = Jsoup.parse(html)
+ documentCache[docId] = doc
+ docId
+ }
+
+ // Define cheerio select function
+ function("__cheerio_select") { args ->
+ val docId = args.getOrNull(0)?.toString() ?: ""
+ var selector = args.getOrNull(1)?.toString() ?: ""
+ val doc = documentCache[docId] ?: return@function "[]"
+ try {
+ // Convert cheerio :contains("text") to jsoup :contains(text)
+ selector = selector.replace(containsRegex, ":contains($1)")
+ val elements = if (selector.isEmpty()) {
+ Elements()
+ } else {
+ doc.select(selector)
+ }
+ val ids = elements.mapIndexed { index, el ->
+ val elId = "$docId:$index:${el.hashCode()}"
+ elementCache[elId] = el
+ elId
+ }
+ // Use simple JSON array construction to avoid Gson issues
+ "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
+ } catch (e: Exception) {
+ "[]"
+ }
+ }
+
+ // Define cheerio find function
+ function("__cheerio_find") { args ->
+ val docId = args.getOrNull(0)?.toString() ?: ""
+ val elementId = args.getOrNull(1)?.toString() ?: ""
+ var selector = args.getOrNull(2)?.toString() ?: ""
+ val element = elementCache[elementId] ?: return@function "[]"
+ try {
+ // Convert cheerio :contains("text") to jsoup :contains(text)
+ selector = selector.replace(containsRegex, ":contains($1)")
+ val elements = element.select(selector)
+ val ids = elements.mapIndexed { index, el ->
+ val elId = "$docId:find:$index:${el.hashCode()}"
+ elementCache[elId] = el
+ elId
+ }
+ // Use simple JSON array construction to avoid Gson issues
+ "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
+ } catch (e: Exception) {
+ "[]"
+ }
+ }
+
+ // Define cheerio text function
+ function("__cheerio_text") { args ->
+ val elementIds = args.getOrNull(1)?.toString() ?: ""
+ val ids = elementIds.split(",").filter { it.isNotEmpty() }
+ val texts = ids.mapNotNull { id ->
+ elementCache[id]?.text()
+ }
+ texts.joinToString(" ")
+ }
+
+ // Define cheerio html function
+ function("__cheerio_html") { args ->
+ val docId = args.getOrNull(0)?.toString() ?: ""
+ val elementId = args.getOrNull(1)?.toString() ?: ""
+ if (elementId.isEmpty()) {
+ documentCache[docId]?.html() ?: ""
+ } else {
+ elementCache[elementId]?.html() ?: ""
+ }
+ }
+
+ // Define cheerio inner html function
+ function("__cheerio_inner_html") { args ->
+ val elementId = args.getOrNull(1)?.toString() ?: ""
+ elementCache[elementId]?.html() ?: ""
+ }
+
+ // Define cheerio attr function
+ function("__cheerio_attr") { args ->
+ val elementId = args.getOrNull(1)?.toString() ?: ""
+ val attrName = args.getOrNull(2)?.toString() ?: ""
+ val value = elementCache[elementId]?.attr(attrName)
+ if (value.isNullOrEmpty()) "__UNDEFINED__" else value
+ }
+
+ // Define cheerio next function
+ function("__cheerio_next") { args ->
+ val docId = args.getOrNull(0)?.toString() ?: ""
+ val elementId = args.getOrNull(1)?.toString() ?: ""
+ val el = elementCache[elementId] ?: return@function "__NONE__"
+ val next = el.nextElementSibling() ?: return@function "__NONE__"
+ val nextId = "$docId:next:${next.hashCode()}"
+ elementCache[nextId] = next
+ nextId
+ }
+
+ // Define cheerio prev function
+ function("__cheerio_prev") { args ->
+ val docId = args.getOrNull(0)?.toString() ?: ""
+ val elementId = args.getOrNull(1)?.toString() ?: ""
+ val el = elementCache[elementId] ?: return@function "__NONE__"
+ val prev = el.previousElementSibling() ?: return@function "__NONE__"
+ val prevId = "$docId:prev:${prev.hashCode()}"
+ elementCache[prevId] = prev
+ prevId
+ }
+
+ // Note: crypto-js is now loaded as a real library (WebJars) before plugin execution.
+
+ // Function to capture results
+ function("__capture_result") { args ->
+ resultJson = args.getOrNull(0)?.toString() ?: "[]"
+ }
+
+ // Inject JavaScript polyfills
+ val settingsJson = gson.toJson(scraperSettings)
+ val polyfillCode = buildPolyfillCode(scraperId, settingsJson)
+ evaluate(polyfillCode)
+
+ // Load real crypto-js into the JS runtime before plugin code runs.
+ loadCryptoJsSourceOrNull()?.let { cryptoJsSource ->
+ evaluate(cryptoJsSource)
+ }
+
+ // Execute plugin code with module wrapper
+ val wrappedCode = """
+ var module = { exports: {} };
+ var exports = module.exports;
+ $code
+ """.trimIndent()
+ evaluate(wrappedCode)
+
+ // Call getStreams and capture result
+ val seasonArg = season?.toString() ?: "undefined"
+ val episodeArg = episode?.toString() ?: "undefined"
+
+ val callCode = """
+ (async function() {
+ try {
+ var getStreams = module.exports.getStreams || globalThis.getStreams;
+ if (!getStreams) {
+ __capture_result(JSON.stringify([]));
+ return;
+ }
+ var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg);
+ __capture_result(JSON.stringify(result || []));
+ } catch (e) {
+ console.error("getStreams error:", e.message || e);
+ __capture_result(JSON.stringify([]));
+ }
+ })();
+ """.trimIndent()
+
+ evaluate(callCode)
+ }
+
+ return parseJsonResults(resultJson)
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Plugin execution failed: ${e.message}", e)
+ throw e
+ } finally {
+ // Clean up caches
+ documentCache.clear()
+ elementCache.clear()
+ }
+ }
+
+ private fun performNativeFetch(url: String, method: String, headersJson: String, body: String): String {
+ return try {
+ val headers = mutableMapOf()
+ try {
+ val headersMap = gson.fromJson(headersJson, Map::class.java)
+ headersMap?.forEach { (k, v) ->
+ if (k != null && v != null) {
+ val key = k.toString()
+ // If callers set Accept-Encoding manually, OkHttp will not transparently decompress.
+ // Strip it so OkHttp can negotiate and decode automatically.
+ if (!key.equals("Accept-Encoding", ignoreCase = true)) {
+ headers[key] = v.toString()
+ }
+ }
+ }
+ } catch (e: Exception) {
+ // Ignore header parsing errors
+ }
+
+ // Default User-Agent
+ if (!headers.containsKey("User-Agent")) {
+ headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
+ }
+
+ val requestBuilder = Request.Builder()
+ .url(url)
+ .headers(Headers.headersOf(*headers.flatMap { listOf(it.key, it.value) }.toTypedArray()))
+
+ when (method.uppercase()) {
+ "POST" -> {
+ val contentType = headers["Content-Type"] ?: "application/x-www-form-urlencoded"
+ requestBuilder.post(body.toRequestBody(contentType.toMediaType()))
+ }
+ "PUT" -> {
+ val contentType = headers["Content-Type"] ?: "application/json"
+ requestBuilder.put(body.toRequestBody(contentType.toMediaType()))
+ }
+ "DELETE" -> requestBuilder.delete()
+ else -> requestBuilder.get()
+ }
+
+ val request = requestBuilder.build()
+ val response = httpClient.newCall(request).execute()
+
+ val responseBodyBytes = response.body?.bytes() ?: ByteArray(0)
+ val contentEncoding = response.header("Content-Encoding")?.lowercase()?.trim()
+ val decodedBytes = try {
+ when (contentEncoding) {
+ "gzip" -> GZIPInputStream(ByteArrayInputStream(responseBodyBytes)).use { it.readBytes() }
+ "deflate" -> InflaterInputStream(ByteArrayInputStream(responseBodyBytes)).use { it.readBytes() }
+ else -> responseBodyBytes
+ }
+ } catch (e: Exception) {
+ // If decoding fails, fall back to raw bytes.
+ responseBodyBytes
+ }
+
+ val charset = response.body?.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
+ val responseBody = try {
+ String(decodedBytes, charset)
+ } catch (e: Exception) {
+ String(decodedBytes, Charsets.UTF_8)
+ }
+ val responseHeaders = mutableMapOf()
+ response.headers.forEach { (name, value) ->
+ responseHeaders[name.lowercase()] = value
+ }
+
+ val result = mapOf(
+ "ok" to response.isSuccessful,
+ "status" to response.code,
+ "statusText" to response.message,
+ "url" to response.request.url.toString(),
+ "body" to responseBody,
+ "headers" to responseHeaders
+ )
+
+ gson.toJson(result)
+ } catch (e: Exception) {
+ Log.e(TAG, "Fetch error: ${e.message}")
+ gson.toJson(mapOf(
+ "ok" to false,
+ "status" to 0,
+ "statusText" to (e.message ?: "Fetch failed"),
+ "url" to url,
+ "body" to "",
+ "headers" to emptyMap()
+ ))
+ }
+ }
+
+ private fun parseUrl(urlString: String): String {
+ return try {
+ val url = URL(urlString)
+ gson.toJson(mapOf(
+ "protocol" to "${url.protocol}:",
+ "host" to if (url.port > 0) "${url.host}:${url.port}" else url.host,
+ "hostname" to url.host,
+ "port" to if (url.port > 0) url.port.toString() else "",
+ "pathname" to (url.path ?: "/"),
+ "search" to if (url.query != null) "?${url.query}" else "",
+ "hash" to if (url.ref != null) "#${url.ref}" else ""
+ ))
+ } catch (e: Exception) {
+ gson.toJson(mapOf(
+ "protocol" to "",
+ "host" to "",
+ "hostname" to "",
+ "port" to "",
+ "pathname" to "/",
+ "search" to "",
+ "hash" to ""
+ ))
+ }
+ }
+
+ private fun buildPolyfillCode(scraperId: String, settingsJson: String): String {
+ return """
+ // Global constants (using globalThis to avoid redeclaration errors)
+ globalThis.SCRAPER_ID = "$scraperId";
+ globalThis.SCRAPER_SETTINGS = $settingsJson;
+ if (typeof TMDB_API_KEY === 'undefined') {
+ globalThis.TMDB_API_KEY = "1865f43a0549ca50d341dd9ab8b29f49";
+ }
+
+ // Fetch implementation (async)
+ var fetch = async function(url, options) {
+ options = options || {};
+ var method = (options.method || 'GET').toUpperCase();
+ var headers = options.headers || {};
+ var body = options.body || '';
+
+ // Add default User-Agent
+ if (!headers['User-Agent']) {
+ headers['User-Agent'] = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36';
+ }
+
+ var result = await __native_fetch(url, method, JSON.stringify(headers), body);
+ var parsed = JSON.parse(result);
+
+ return {
+ ok: parsed.ok,
+ status: parsed.status,
+ statusText: parsed.statusText,
+ url: parsed.url,
+ headers: {
+ get: function(name) {
+ return parsed.headers[name.toLowerCase()] || null;
+ }
+ },
+ text: function() {
+ return Promise.resolve(parsed.body);
+ },
+ json: function() {
+ try {
+ return Promise.resolve(JSON.parse(parsed.body));
+ } catch (e) {
+ return Promise.reject(new Error('JSON parse error: ' + e.message));
+ }
+ }
+ };
+ };
+
+ // URL class
+ var URL = function(urlString) {
+ var parsed = __parse_url(urlString);
+ var data = JSON.parse(parsed);
+ this.href = urlString;
+ this.protocol = data.protocol;
+ this.host = data.host;
+ this.hostname = data.hostname;
+ this.port = data.port;
+ this.pathname = data.pathname;
+ this.search = data.search;
+ this.hash = data.hash;
+ };
+
+ // URLSearchParams class
+ var URLSearchParams = function(init) {
+ this._params = {};
+ var self = this;
+ if (init && typeof init === 'object' && !Array.isArray(init)) {
+ Object.keys(init).forEach(function(key) {
+ self._params[key] = String(init[key]);
+ });
+ } else if (typeof init === 'string') {
+ init.replace(/^\?/, '').split('&').forEach(function(pair) {
+ var parts = pair.split('=');
+ if (parts[0]) {
+ self._params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1] || '');
+ }
+ });
+ }
+ };
+ URLSearchParams.prototype.toString = function() {
+ var self = this;
+ return Object.keys(this._params).map(function(key) {
+ return encodeURIComponent(key) + '=' + encodeURIComponent(self._params[key]);
+ }).join('&');
+ };
+ URLSearchParams.prototype.get = function(key) {
+ return this._params.hasOwnProperty(key) ? this._params[key] : null;
+ };
+ URLSearchParams.prototype.set = function(key, value) {
+ this._params[key] = String(value);
+ };
+ URLSearchParams.prototype.append = function(key, value) {
+ this._params[key] = String(value);
+ };
+ URLSearchParams.prototype.has = function(key) {
+ return this._params.hasOwnProperty(key);
+ };
+ URLSearchParams.prototype.delete = function(key) {
+ delete this._params[key];
+ };
+
+ // Cheerio implementation
+ var cheerio = {
+ load: function(html) {
+ var docId = __cheerio_load(html);
+
+ var $ = function(selector, context) {
+ // Handle $(wrapper) - return wrapper as-is
+ if (selector && selector._elementIds) {
+ return selector;
+ }
+ // Handle $(selector, context) pattern
+ if (context && context._elementIds && context._elementIds.length > 0) {
+ // Search within context element
+ var allIds = [];
+ for (var i = 0; i < context._elementIds.length; i++) {
+ var childIdsJson = __cheerio_find(docId, context._elementIds[i], selector);
+ var childIds = JSON.parse(childIdsJson);
+ allIds = allIds.concat(childIds);
+ }
+ return createCheerioWrapperFromIds(docId, allIds);
+ }
+ // Standard $(selector) call
+ return createCheerioWrapper(docId, selector);
+ };
+
+ $.html = function(el) {
+ if (el && el._elementIds && el._elementIds.length > 0) {
+ return __cheerio_html(docId, el._elementIds[0]);
+ }
+ return __cheerio_html(docId, '');
+ };
+
+ return $;
+ }
+ };
+
+ function createCheerioWrapper(docId, selector) {
+ var elementIds;
+ if (typeof selector === 'string') {
+ var idsJson = __cheerio_select(docId, selector);
+ elementIds = JSON.parse(idsJson);
+ } else {
+ elementIds = [];
+ }
+
+ var wrapper = {
+ _docId: docId,
+ _elementIds: elementIds,
+ length: elementIds.length,
+
+ each: function(callback) {
+ for (var i = 0; i < elementIds.length; i++) {
+ var elWrapper = createCheerioWrapperFromIds(docId, [elementIds[i]]);
+ callback.call(elWrapper, i, elWrapper);
+ }
+ return wrapper;
+ },
+
+ find: function(sel) {
+ var allIds = [];
+ for (var i = 0; i < elementIds.length; i++) {
+ var childIdsJson = __cheerio_find(docId, elementIds[i], sel);
+ var childIds = JSON.parse(childIdsJson);
+ allIds = allIds.concat(childIds);
+ }
+ return createCheerioWrapperFromIds(docId, allIds);
+ },
+
+ text: function() {
+ if (elementIds.length === 0) return '';
+ return __cheerio_text(docId, elementIds.join(','));
+ },
+
+ html: function() {
+ if (elementIds.length === 0) return '';
+ return __cheerio_inner_html(docId, elementIds[0]);
+ },
+
+ attr: function(name) {
+ if (elementIds.length === 0) return undefined;
+ var val = __cheerio_attr(docId, elementIds[0], name);
+ return val === '__UNDEFINED__' ? undefined : val;
+ },
+
+ first: function() {
+ return createCheerioWrapperFromIds(docId, elementIds.length > 0 ? [elementIds[0]] : []);
+ },
+
+ last: function() {
+ return createCheerioWrapperFromIds(docId, elementIds.length > 0 ? [elementIds[elementIds.length - 1]] : []);
+ },
+
+ next: function() {
+ var nextIds = [];
+ for (var i = 0; i < elementIds.length; i++) {
+ var nextId = __cheerio_next(docId, elementIds[i]);
+ if (nextId && nextId !== '__NONE__') {
+ nextIds.push(nextId);
+ }
+ }
+ return createCheerioWrapperFromIds(docId, nextIds);
+ },
+
+ prev: function() {
+ var prevIds = [];
+ for (var i = 0; i < elementIds.length; i++) {
+ var prevId = __cheerio_prev(docId, elementIds[i]);
+ if (prevId && prevId !== '__NONE__') {
+ prevIds.push(prevId);
+ }
+ }
+ return createCheerioWrapperFromIds(docId, prevIds);
+ },
+
+ eq: function(index) {
+ if (index >= 0 && index < elementIds.length) {
+ return createCheerioWrapperFromIds(docId, [elementIds[index]]);
+ }
+ return createCheerioWrapperFromIds(docId, []);
+ },
+
+ get: function(index) {
+ if (typeof index === 'number') {
+ if (index >= 0 && index < elementIds.length) {
+ return createCheerioWrapperFromIds(docId, [elementIds[index]]);
+ }
+ return undefined;
+ }
+ return elementIds.map(function(id) {
+ return createCheerioWrapperFromIds(docId, [id]);
+ });
+ },
+
+ map: function(callback) {
+ var results = [];
+ for (var i = 0; i < elementIds.length; i++) {
+ var elWrapper = createCheerioWrapperFromIds(docId, [elementIds[i]]);
+ var result = callback.call(elWrapper, i, elWrapper);
+ if (result !== undefined && result !== null) {
+ results.push(result);
+ }
+ }
+ // Return object with get() for cheerio compatibility
+ return {
+ length: results.length,
+ get: function(index) {
+ if (typeof index === 'number') {
+ return results[index];
+ }
+ return results;
+ },
+ toArray: function() {
+ return results;
+ }
+ };
+ },
+
+ filter: function(selectorOrCallback) {
+ if (typeof selectorOrCallback === 'function') {
+ var filteredIds = [];
+ for (var i = 0; i < elementIds.length; i++) {
+ var elWrapper = createCheerioWrapperFromIds(docId, [elementIds[i]]);
+ var result = selectorOrCallback.call(elWrapper, i, elWrapper);
+ if (result) {
+ filteredIds.push(elementIds[i]);
+ }
+ }
+ return createCheerioWrapperFromIds(docId, filteredIds);
+ }
+ return wrapper;
+ },
+
+ children: function(sel) {
+ return this.find(sel || '*');
+ },
+
+ parent: function() {
+ return createCheerioWrapperFromIds(docId, []);
+ },
+
+ toArray: function() {
+ return elementIds.map(function(id) {
+ return createCheerioWrapperFromIds(docId, [id]);
+ });
+ }
+ };
+
+ return wrapper;
+ }
+
+ function createCheerioWrapperFromIds(docId, ids) {
+ var wrapper = {
+ _docId: docId,
+ _elementIds: ids,
+ length: ids.length,
+
+ each: function(callback) {
+ for (var i = 0; i < ids.length; i++) {
+ var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
+ callback.call(elWrapper, i, elWrapper);
+ }
+ return wrapper;
+ },
+
+ find: function(sel) {
+ var allIds = [];
+ for (var i = 0; i < ids.length; i++) {
+ var childIdsJson = __cheerio_find(docId, ids[i], sel);
+ var childIds = JSON.parse(childIdsJson);
+ allIds = allIds.concat(childIds);
+ }
+ return createCheerioWrapperFromIds(docId, allIds);
+ },
+
+ text: function() {
+ if (ids.length === 0) return '';
+ return __cheerio_text(docId, ids.join(','));
+ },
+
+ html: function() {
+ if (ids.length === 0) return '';
+ return __cheerio_inner_html(docId, ids[0]);
+ },
+
+ attr: function(name) {
+ if (ids.length === 0) return undefined;
+ var val = __cheerio_attr(docId, ids[0], name);
+ return val === '__UNDEFINED__' ? undefined : val;
+ },
+
+ first: function() {
+ return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[0]] : []);
+ },
+
+ last: function() {
+ return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[ids.length - 1]] : []);
+ },
+
+ next: function() {
+ var nextIds = [];
+ for (var i = 0; i < ids.length; i++) {
+ var nextId = __cheerio_next(docId, ids[i]);
+ if (nextId && nextId !== '__NONE__') {
+ nextIds.push(nextId);
+ }
+ }
+ return createCheerioWrapperFromIds(docId, nextIds);
+ },
+
+ prev: function() {
+ var prevIds = [];
+ for (var i = 0; i < ids.length; i++) {
+ var prevId = __cheerio_prev(docId, ids[i]);
+ if (prevId && prevId !== '__NONE__') {
+ prevIds.push(prevId);
+ }
+ }
+ return createCheerioWrapperFromIds(docId, prevIds);
+ },
+
+ eq: function(index) {
+ if (index >= 0 && index < ids.length) {
+ return createCheerioWrapperFromIds(docId, [ids[index]]);
+ }
+ return createCheerioWrapperFromIds(docId, []);
+ },
+
+ get: function(index) {
+ if (typeof index === 'number') {
+ if (index >= 0 && index < ids.length) {
+ return createCheerioWrapperFromIds(docId, [ids[index]]);
+ }
+ return undefined;
+ }
+ return ids.map(function(id) {
+ return createCheerioWrapperFromIds(docId, [id]);
+ });
+ },
+
+ map: function(callback) {
+ var results = [];
+ for (var i = 0; i < ids.length; i++) {
+ var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
+ var result = callback.call(elWrapper, i, elWrapper);
+ if (result !== undefined && result !== null) {
+ results.push(result);
+ }
+ }
+ // Return object with get() for cheerio compatibility
+ return {
+ length: results.length,
+ get: function(index) {
+ if (typeof index === 'number') {
+ return results[index];
+ }
+ return results;
+ },
+ toArray: function() {
+ return results;
+ }
+ };
+ },
+
+ filter: function(selectorOrCallback) {
+ if (typeof selectorOrCallback === 'function') {
+ var filteredIds = [];
+ for (var i = 0; i < ids.length; i++) {
+ var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
+ var result = selectorOrCallback.call(elWrapper, i, elWrapper);
+ if (result) {
+ filteredIds.push(ids[i]);
+ }
+ }
+ return createCheerioWrapperFromIds(docId, filteredIds);
+ }
+ return wrapper;
+ },
+
+ children: function(sel) {
+ return this.find(sel || '*');
+ },
+
+ parent: function() {
+ return createCheerioWrapperFromIds(docId, []);
+ },
+
+ toArray: function() {
+ return ids.map(function(id) {
+ return createCheerioWrapperFromIds(docId, [id]);
+ });
+ }
+ };
+
+ return wrapper;
+ }
+
+ // Require function for CommonJS modules
+ var require = function(moduleName) {
+ if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') {
+ return cheerio;
+ }
+ if (moduleName === 'crypto-js') {
+ if (globalThis.CryptoJS) return globalThis.CryptoJS;
+ throw new Error("Module 'crypto-js' is not loaded");
+ }
+ throw new Error("Module '" + moduleName + "' is not available");
+ };
+
+ // Array.prototype.flat polyfill
+ if (!Array.prototype.flat) {
+ Array.prototype.flat = function(depth) {
+ depth = depth === undefined ? 1 : Math.floor(depth);
+ if (depth < 1) return Array.prototype.slice.call(this);
+ return (function flatten(arr, d) {
+ return d > 0
+ ? arr.reduce(function(acc, val) {
+ return acc.concat(Array.isArray(val) ? flatten(val, d - 1) : val);
+ }, [])
+ : arr.slice();
+ })(this, depth);
+ };
+ }
+
+ // Array.prototype.flatMap polyfill
+ if (!Array.prototype.flatMap) {
+ Array.prototype.flatMap = function(callback, thisArg) {
+ return this.map(callback, thisArg).flat();
+ };
+ }
+
+ // Object.entries polyfill
+ if (!Object.entries) {
+ Object.entries = function(obj) {
+ var result = [];
+ for (var key in obj) {
+ if (obj.hasOwnProperty(key)) {
+ result.push([key, obj[key]]);
+ }
+ }
+ return result;
+ };
+ }
+
+ // Object.fromEntries polyfill
+ if (!Object.fromEntries) {
+ Object.fromEntries = function(entries) {
+ var result = {};
+ for (var i = 0; i < entries.length; i++) {
+ result[entries[i][0]] = entries[i][1];
+ }
+ return result;
+ };
+ }
+
+ // String.prototype.replaceAll polyfill
+ if (!String.prototype.replaceAll) {
+ String.prototype.replaceAll = function(search, replace) {
+ if (search instanceof RegExp) {
+ if (!search.global) {
+ throw new TypeError('replaceAll must be called with a global RegExp');
+ }
+ return this.replace(search, replace);
+ }
+ return this.split(search).join(replace);
+ };
+ }
+ """.trimIndent()
+ }
+
+ private fun parseJsonResults(json: String): List {
+ return try {
+ val listType = object : com.google.gson.reflect.TypeToken>>() {}.type
+ val results: List>? = gson.fromJson(json, listType)
+ results?.mapNotNull { item ->
+ // Handle URL - could be string or object with url property
+ val urlValue = item["url"]
+ val url = when (urlValue) {
+ is String -> urlValue.takeIf { it.isNotBlank() && !it.contains("[object") }
+ is Map<*, *> -> (urlValue["url"] as? String)?.takeIf { it.isNotBlank() }
+ else -> null
+ } ?: return@mapNotNull null
+
+ // Parse headers if present
+ val headersValue = item["headers"]
+ val headers: Map? = when (headersValue) {
+ is Map<*, *> -> headersValue.entries
+ .filter { it.key is String && it.value is String }
+ .associate { (it.key as String) to (it.value as String) }
+ .takeIf { it.isNotEmpty() }
+ else -> null
+ }
+
+ LocalScraperResult(
+ title = item["title"]?.toString()?.takeIf { !it.contains("[object") }
+ ?: item["name"]?.toString()?.takeIf { !it.contains("[object") }
+ ?: "Unknown",
+ name = item["name"]?.toString()?.takeIf { !it.contains("[object") },
+ url = url,
+ quality = item["quality"]?.toString()?.takeIf { !it.contains("[object") },
+ size = item["size"]?.toString()?.takeIf { !it.contains("[object") },
+ language = item["language"]?.toString()?.takeIf { !it.contains("[object") },
+ provider = item["provider"]?.toString()?.takeIf { !it.contains("[object") },
+ type = item["type"]?.toString()?.takeIf { !it.contains("[object") },
+ seeders = (item["seeders"] as? Number)?.toInt(),
+ peers = (item["peers"] as? Number)?.toInt(),
+ infoHash = item["infoHash"]?.toString()?.takeIf { !it.contains("[object") },
+ headers = headers
+ )
+ }?.filter { it.url.isNotBlank() } ?: emptyList()
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to parse results: ${e.message}")
+ emptyList()
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt
new file mode 100644
index 00000000..35c99d91
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt
@@ -0,0 +1,212 @@
+package com.nuvio.tv.core.tmdb
+
+import android.util.Log
+import com.nuvio.tv.data.remote.api.TmdbApi
+import com.nuvio.tv.data.remote.api.TmdbEpisode
+import com.nuvio.tv.domain.model.ContentType
+import com.nuvio.tv.domain.model.MetaCastMember
+import com.nuvio.tv.domain.model.MetaCompany
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val TAG = "TmdbMetadataService"
+private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
+
+@Singleton
+class TmdbMetadataService @Inject constructor(
+ private val tmdbApi: TmdbApi
+) {
+ suspend fun fetchEnrichment(tmdbId: String, contentType: ContentType): TmdbEnrichment? =
+ withContext(Dispatchers.IO) {
+ val numericId = tmdbId.toIntOrNull() ?: return@withContext null
+ val tmdbType = when (contentType) {
+ ContentType.SERIES, ContentType.TV -> "tv"
+ else -> "movie"
+ }
+
+ try {
+ val details = when (tmdbType) {
+ "tv" -> tmdbApi.getTvDetails(numericId, TMDB_API_KEY)
+ else -> tmdbApi.getMovieDetails(numericId, TMDB_API_KEY)
+ }.body()
+
+ val credits = when (tmdbType) {
+ "tv" -> tmdbApi.getTvCredits(numericId, TMDB_API_KEY)
+ else -> tmdbApi.getMovieCredits(numericId, TMDB_API_KEY)
+ }.body()
+
+ val images = when (tmdbType) {
+ "tv" -> tmdbApi.getTvImages(numericId, TMDB_API_KEY)
+ else -> tmdbApi.getMovieImages(numericId, TMDB_API_KEY)
+ }.body()
+
+ val genres = details?.genres?.mapNotNull { genre ->
+ genre.name.trim().takeIf { name -> name.isNotBlank() }
+ } ?: emptyList()
+ val description = details?.overview?.takeIf { it.isNotBlank() }
+ val releaseInfo = details?.releaseDate
+ ?: details?.firstAirDate
+ val rating = details?.voteAverage
+ val runtime = details?.runtime ?: details?.episodeRunTime?.firstOrNull()
+ val countries = details?.productionCountries
+ ?.mapNotNull { it.name?.trim()?.takeIf { name -> name.isNotBlank() } }
+ ?.takeIf { it.isNotEmpty() }
+ ?: details?.originCountry?.takeIf { it.isNotEmpty() }
+ val language = details?.originalLanguage?.takeIf { it.isNotBlank() }
+ val productionCompanies = details?.productionCompanies
+ .orEmpty()
+ .mapNotNull { company ->
+ val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ MetaCompany(
+ name = name,
+ logo = buildImageUrl(company.logoPath, size = "w300")
+ )
+ }
+ val networks = details?.networks
+ .orEmpty()
+ .mapNotNull { network ->
+ val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ MetaCompany(
+ name = name,
+ logo = buildImageUrl(network.logoPath, size = "w300")
+ )
+ }
+ val poster = buildImageUrl(details?.posterPath, size = "w500")
+ val backdrop = buildImageUrl(details?.backdropPath, size = "w1280")
+
+ val logoPath = images?.logos
+ ?.sortedWith(
+ compareByDescending { it.iso6391 == "en" }
+ .thenByDescending { it.iso6391 == null }
+ )
+ ?.firstOrNull()
+ ?.filePath
+
+ val logo = buildImageUrl(logoPath, size = "w500")
+
+ val castMembers = credits?.cast
+ .orEmpty()
+ .mapNotNull { member ->
+ val name = member.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ MetaCastMember(
+ name = name,
+ character = member.character?.takeIf { it.isNotBlank() },
+ photo = buildImageUrl(member.profilePath, size = "w500")
+ )
+ }
+
+ val director = credits?.crew
+ .orEmpty()
+ .filter { it.job.equals("Director", ignoreCase = true) }
+ .mapNotNull { it.name?.trim()?.takeIf { name -> name.isNotBlank() } }
+
+ val writer = credits?.crew
+ .orEmpty()
+ .filter { crew ->
+ val job = crew.job?.lowercase() ?: ""
+ job.contains("writer") || job.contains("screenplay")
+ }
+ .mapNotNull { it.name?.trim()?.takeIf { name -> name.isNotBlank() } }
+
+ if (
+ genres.isEmpty() && description == null && backdrop == null && logo == null &&
+ poster == null && castMembers.isEmpty() && director.isEmpty() && writer.isEmpty() &&
+ releaseInfo == null && rating == null && runtime == null && countries.isNullOrEmpty() && language == null &&
+ productionCompanies.isEmpty() && networks.isEmpty()
+ ) {
+ return@withContext null
+ }
+
+ TmdbEnrichment(
+ description = description,
+ genres = genres,
+ backdrop = backdrop,
+ logo = logo,
+ poster = poster,
+ castMembers = castMembers,
+ releaseInfo = releaseInfo,
+ rating = rating,
+ runtimeMinutes = runtime,
+ director = director,
+ writer = writer,
+ productionCompanies = productionCompanies,
+ networks = networks,
+ countries = countries,
+ language = language
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to fetch TMDB enrichment: ${e.message}", e)
+ null
+ }
+ }
+
+ suspend fun fetchEpisodeEnrichment(
+ tmdbId: String,
+ seasonNumbers: List
+ ): Map, TmdbEpisodeEnrichment> = withContext(Dispatchers.IO) {
+ val numericId = tmdbId.toIntOrNull() ?: return@withContext emptyMap()
+ val result = mutableMapOf, TmdbEpisodeEnrichment>()
+
+ seasonNumbers.distinct().forEach { season ->
+ try {
+ val response = tmdbApi.getTvSeasonDetails(numericId, season, TMDB_API_KEY)
+ val episodes = response.body()?.episodes.orEmpty()
+ episodes.forEach { ep ->
+ val epNum = ep.episodeNumber ?: return@forEach
+ result[season to epNum] = ep.toEnrichment()
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to fetch TMDB season $season: ${e.message}")
+ }
+ }
+
+ result
+ }
+
+ private fun buildImageUrl(path: String?, size: String): String? {
+ val clean = path?.trim()?.takeIf { it.isNotBlank() } ?: return null
+ return "https://image.tmdb.org/t/p/$size$clean"
+ }
+}
+
+data class TmdbEnrichment(
+ val description: String?,
+ val genres: List,
+ val backdrop: String?,
+ val logo: String?,
+ val poster: String?,
+ val castMembers: List,
+ val releaseInfo: String?,
+ val rating: Double?,
+ val runtimeMinutes: Int?,
+ val director: List,
+ val writer: List,
+ val productionCompanies: List,
+ val networks: List,
+ val countries: List?,
+ val language: String?
+)
+
+data class TmdbEpisodeEnrichment(
+ val title: String?,
+ val overview: String?,
+ val thumbnail: String?,
+ val airDate: String?,
+ val runtimeMinutes: Int?
+)
+
+private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment {
+ val title = name?.takeIf { it.isNotBlank() }
+ val overview = overview?.takeIf { it.isNotBlank() }
+ val thumbnail = stillPath?.takeIf { it.isNotBlank() }?.let { "https://image.tmdb.org/t/p/w500$it" }
+ val airDate = airDate?.takeIf { it.isNotBlank() }
+ return TmdbEpisodeEnrichment(
+ title = title,
+ overview = overview,
+ thumbnail = thumbnail,
+ airDate = airDate,
+ runtimeMinutes = runtime
+ )
+}
diff --git a/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt
new file mode 100644
index 00000000..1bd8312b
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt
@@ -0,0 +1,215 @@
+package com.nuvio.tv.core.tmdb
+
+import android.util.Log
+import com.nuvio.tv.data.remote.api.TmdbApi
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.util.concurrent.ConcurrentHashMap
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private const val TAG = "TmdbService"
+private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
+
+/**
+ * Service to handle TMDB ID conversions and lookups.
+ * Provides caching to avoid redundant API calls.
+ */
+@Singleton
+class TmdbService @Inject constructor(
+ private val tmdbApi: TmdbApi
+) {
+ // Cache: IMDB ID -> TMDB ID
+ private val imdbToTmdbCache = ConcurrentHashMap()
+
+ // Cache: TMDB ID -> IMDB ID
+ private val tmdbToImdbCache = ConcurrentHashMap()
+
+ // Mutex for thread-safe cache operations
+ private val cacheMutex = Mutex()
+
+ /**
+ * Convert an IMDB ID to a TMDB ID.
+ *
+ * @param imdbId The IMDB ID (e.g., "tt0133093")
+ * @param mediaType The media type ("movie" or "series"/"tv")
+ * @return The TMDB ID, or null if not found
+ */
+ suspend fun imdbToTmdb(imdbId: String, mediaType: String): Int? = withContext(Dispatchers.IO) {
+ // Validate IMDB ID format
+ if (!imdbId.startsWith("tt")) {
+ Log.w(TAG, "Invalid IMDB ID format: $imdbId")
+ return@withContext null
+ }
+
+ // Check cache first
+ imdbToTmdbCache[imdbId]?.let { cached ->
+ Log.d(TAG, "Cache hit: IMDB $imdbId -> TMDB $cached")
+ return@withContext cached
+ }
+
+ try {
+ Log.d(TAG, "Looking up TMDB ID for IMDB: $imdbId (type: $mediaType)")
+
+ val response = tmdbApi.findByExternalId(
+ externalId = imdbId,
+ apiKey = TMDB_API_KEY,
+ externalSource = "imdb_id"
+ )
+
+ if (!response.isSuccessful) {
+ Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}")
+ return@withContext null
+ }
+
+ val body = response.body() ?: return@withContext null
+
+ // Determine which results to use based on media type
+ val normalizedType = normalizeMediaType(mediaType)
+ val result = when (normalizedType) {
+ "movie" -> body.movieResults?.firstOrNull()
+ "tv", "series" -> body.tvResults?.firstOrNull()
+ else -> body.movieResults?.firstOrNull() ?: body.tvResults?.firstOrNull()
+ }
+
+ result?.let { found ->
+ Log.d(TAG, "Found TMDB ID: ${found.id} for IMDB: $imdbId")
+
+ // Cache both directions
+ cacheMutex.withLock {
+ imdbToTmdbCache[imdbId] = found.id
+ tmdbToImdbCache[found.id] = imdbId
+ }
+
+ return@withContext found.id
+ }
+
+ Log.w(TAG, "No TMDB result found for IMDB: $imdbId")
+ null
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error looking up TMDB ID for $imdbId: ${e.message}", e)
+ null
+ }
+ }
+
+ /**
+ * Convert a TMDB ID to an IMDB ID.
+ *
+ * @param tmdbId The TMDB ID
+ * @param mediaType The media type ("movie" or "series"/"tv")
+ * @return The IMDB ID, or null if not found
+ */
+ suspend fun tmdbToImdb(tmdbId: Int, mediaType: String): String? = withContext(Dispatchers.IO) {
+ // Check cache first
+ tmdbToImdbCache[tmdbId]?.let { cached ->
+ Log.d(TAG, "Cache hit: TMDB $tmdbId -> IMDB $cached")
+ return@withContext cached
+ }
+
+ try {
+ Log.d(TAG, "Looking up IMDB ID for TMDB: $tmdbId (type: $mediaType)")
+
+ val normalizedType = normalizeMediaType(mediaType)
+ val response = when (normalizedType) {
+ "movie" -> tmdbApi.getMovieExternalIds(tmdbId, TMDB_API_KEY)
+ "tv", "series" -> tmdbApi.getTvExternalIds(tmdbId, TMDB_API_KEY)
+ else -> tmdbApi.getMovieExternalIds(tmdbId, TMDB_API_KEY)
+ }
+
+ if (!response.isSuccessful) {
+ Log.e(TAG, "TMDB API error: ${response.code()} - ${response.message()}")
+ return@withContext null
+ }
+
+ val body = response.body() ?: return@withContext null
+
+ body.imdbId?.let { imdbId ->
+ Log.d(TAG, "Found IMDB ID: $imdbId for TMDB: $tmdbId")
+
+ // Cache both directions
+ cacheMutex.withLock {
+ tmdbToImdbCache[tmdbId] = imdbId
+ imdbToTmdbCache[imdbId] = tmdbId
+ }
+
+ return@withContext imdbId
+ }
+
+ Log.w(TAG, "No IMDB ID found for TMDB: $tmdbId")
+ null
+
+ } catch (e: Exception) {
+ Log.e(TAG, "Error looking up IMDB ID for $tmdbId: ${e.message}", e)
+ null
+ }
+ }
+
+ /**
+ * Get a TMDB ID from a video ID string.
+ * Handles both IMDB IDs (tt...) and TMDB IDs.
+ *
+ * @param videoId The video ID (can be IMDB or TMDB format)
+ * @param mediaType The media type
+ * @return The TMDB ID as a string, or null if conversion failed
+ */
+ suspend fun ensureTmdbId(videoId: String, mediaType: String): String? {
+ // Check if it's already a TMDB ID (numeric or prefixed)
+ val cleanId = videoId
+ .removePrefix("tmdb:")
+ .removePrefix("movie:")
+ .removePrefix("series:")
+
+ // Stremio-style series ids can look like: tt1234567:season:episode
+ // Plugins/TMDB lookup need the base external id only.
+ val idPart = cleanId
+ .substringBefore(':')
+ .substringBefore('/')
+ .trim()
+
+ // If it's an IMDB ID, convert it
+ if (idPart.startsWith("tt")) {
+ val tmdbId = imdbToTmdb(idPart, normalizeMediaType(mediaType))
+ return tmdbId?.toString()
+ }
+
+ // If it looks like a numeric ID, assume it's already a TMDB ID
+ if (idPart.all { it.isDigit() }) {
+ return idPart
+ }
+
+ // Unknown format
+ Log.w(TAG, "Unknown video ID format: $videoId")
+ return null
+ }
+
+ /**
+ * Normalize media type to consistent format
+ */
+ private fun normalizeMediaType(mediaType: String): String {
+ return when (mediaType.lowercase()) {
+ "series", "tv", "show", "tvshow" -> "tv"
+ "movie", "film" -> "movie"
+ else -> mediaType.lowercase()
+ }
+ }
+
+ /**
+ * Clear all caches
+ */
+ fun clearCache() {
+ imdbToTmdbCache.clear()
+ tmdbToImdbCache.clear()
+ Log.d(TAG, "Cache cleared")
+ }
+
+ /**
+ * Pre-populate cache with known mappings
+ */
+ fun preCacheMapping(imdbId: String, tmdbId: Int) {
+ imdbToTmdbCache[imdbId] = tmdbId
+ tmdbToImdbCache[tmdbId] = imdbId
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/local/AddonPreferences.kt b/app/src/main/java/com/nuvio/tv/data/local/AddonPreferences.kt
new file mode 100644
index 00000000..ebe6e6ad
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/local/AddonPreferences.kt
@@ -0,0 +1,45 @@
+package com.nuvio.tv.data.local
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = "addon_preferences")
+
+@Singleton
+class AddonPreferences @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private val addonUrlsKey = stringSetPreferencesKey("installed_addon_urls")
+
+ val installedAddonUrls: Flow> = context.dataStore.data
+ .map { preferences ->
+ preferences[addonUrlsKey] ?: getDefaultAddons()
+ }
+
+ suspend fun addAddon(url: String) {
+ context.dataStore.edit { preferences ->
+ val currentUrls = preferences[addonUrlsKey] ?: getDefaultAddons()
+ preferences[addonUrlsKey] = currentUrls + url
+ }
+ }
+
+ suspend fun removeAddon(url: String) {
+ context.dataStore.edit { preferences ->
+ val currentUrls = preferences[addonUrlsKey] ?: emptySet()
+ preferences[addonUrlsKey] = currentUrls - url
+ }
+ }
+
+ private fun getDefaultAddons(): Set = setOf(
+ "https://v3-cinemeta.strem.io"
+ )
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/local/LibraryPreferences.kt b/app/src/main/java/com/nuvio/tv/data/local/LibraryPreferences.kt
new file mode 100644
index 00000000..20163a6e
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/local/LibraryPreferences.kt
@@ -0,0 +1,67 @@
+package com.nuvio.tv.data.local
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringSetPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.google.gson.Gson
+import com.nuvio.tv.domain.model.SavedLibraryItem
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.libraryDataStore: DataStore by preferencesDataStore(name = "library_preferences")
+
+@Singleton
+class LibraryPreferences @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private val gson = Gson()
+ private val libraryItemsKey = stringSetPreferencesKey("library_items")
+
+ val libraryItems: Flow> = context.libraryDataStore.data
+ .map { preferences ->
+ val raw = preferences[libraryItemsKey] ?: emptySet()
+ raw.mapNotNull { json ->
+ runCatching { gson.fromJson(json, SavedLibraryItem::class.java) }.getOrNull()
+ }
+ }
+
+ fun isInLibrary(itemId: String, itemType: String): Flow {
+ return libraryItems.map { items ->
+ items.any { it.id == itemId && it.type.equals(itemType, ignoreCase = true) }
+ }
+ }
+
+ suspend fun addItem(item: SavedLibraryItem) {
+ context.libraryDataStore.edit { preferences ->
+ val current = preferences[libraryItemsKey] ?: emptySet()
+ val filtered = current.filterNot { json ->
+ runCatching {
+ gson.fromJson(json, SavedLibraryItem::class.java)
+ }.getOrNull()?.let { saved ->
+ saved.id == item.id && saved.type.equals(item.type, ignoreCase = true)
+ } ?: false
+ }
+ preferences[libraryItemsKey] = filtered.toSet() + gson.toJson(item)
+ }
+ }
+
+ suspend fun removeItem(itemId: String, itemType: String) {
+ context.libraryDataStore.edit { preferences ->
+ val current = preferences[libraryItemsKey] ?: emptySet()
+ val filtered = current.filterNot { json ->
+ runCatching {
+ gson.fromJson(json, SavedLibraryItem::class.java)
+ }.getOrNull()?.let { saved ->
+ saved.id == itemId && saved.type.equals(itemType, ignoreCase = true)
+ } ?: false
+ }
+ preferences[libraryItemsKey] = filtered.toSet()
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/local/PluginDataStore.kt b/app/src/main/java/com/nuvio/tv/data/local/PluginDataStore.kt
new file mode 100644
index 00000000..cf9d923d
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/local/PluginDataStore.kt
@@ -0,0 +1,186 @@
+package com.nuvio.tv.data.local
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.nuvio.tv.domain.model.PluginRepository
+import com.nuvio.tv.domain.model.ScraperInfo
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.Types
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.pluginDataStore: DataStore by preferencesDataStore(name = "plugin_settings")
+
+@Singleton
+class PluginDataStore @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val moshi: Moshi
+) {
+ private val dataStore = context.pluginDataStore
+
+ private val repositoriesKey = stringPreferencesKey("repositories")
+ private val scrapersKey = stringPreferencesKey("scrapers")
+ private val pluginsEnabledKey = booleanPreferencesKey("plugins_enabled")
+ private val scraperSettingsKey = stringPreferencesKey("scraper_settings")
+
+ private val repoListType = Types.newParameterizedType(List::class.java, PluginRepository::class.java)
+ private val scraperListType = Types.newParameterizedType(List::class.java, ScraperInfo::class.java)
+ private val settingsMapType = Types.newParameterizedType(
+ Map::class.java,
+ String::class.java,
+ Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)
+ )
+
+ // Plugin code directory
+ private val codeDir: File
+ get() = File(context.filesDir, "plugin_code").also { it.mkdirs() }
+
+ // Repositories
+ val repositories: Flow> = dataStore.data.map { prefs ->
+ prefs[repositoriesKey]?.let { json ->
+ try {
+ moshi.adapter>(repoListType).fromJson(json) ?: emptyList()
+ } catch (e: Exception) {
+ emptyList()
+ }
+ } ?: emptyList()
+ }
+
+ suspend fun saveRepositories(repos: List) {
+ val json = moshi.adapter>(repoListType).toJson(repos)
+ dataStore.edit { prefs ->
+ prefs[repositoriesKey] = json
+ }
+ }
+
+ suspend fun addRepository(repo: PluginRepository) {
+ val current = repositories.first().toMutableList()
+ current.removeAll { it.id == repo.id }
+ current.add(repo)
+ saveRepositories(current)
+ }
+
+ suspend fun removeRepository(repoId: String) {
+ val current = repositories.first().toMutableList()
+ current.removeAll { it.id == repoId }
+ saveRepositories(current)
+ }
+
+ suspend fun updateRepository(repo: PluginRepository) {
+ val current = repositories.first().toMutableList()
+ val index = current.indexOfFirst { it.id == repo.id }
+ if (index >= 0) {
+ current[index] = repo
+ saveRepositories(current)
+ }
+ }
+
+ // Scrapers
+ val scrapers: Flow> = dataStore.data.map { prefs ->
+ prefs[scrapersKey]?.let { json ->
+ try {
+ moshi.adapter>(scraperListType).fromJson(json) ?: emptyList()
+ } catch (e: Exception) {
+ emptyList()
+ }
+ } ?: emptyList()
+ }
+
+ suspend fun saveScrapers(scrapers: List) {
+ val json = moshi.adapter>(scraperListType).toJson(scrapers)
+ dataStore.edit { prefs ->
+ prefs[scrapersKey] = json
+ }
+ }
+
+ suspend fun setScraperEnabled(scraperId: String, enabled: Boolean) {
+ val current = scrapers.first().toMutableList()
+ val index = current.indexOfFirst { it.id == scraperId }
+ if (index >= 0) {
+ val scraper = current[index]
+ // Only enable if manifest allows
+ if (enabled && !scraper.manifestEnabled) return
+ current[index] = scraper.copy(enabled = enabled)
+ saveScrapers(current)
+ }
+ }
+
+ // Plugins enabled global toggle
+ val pluginsEnabled: Flow = dataStore.data.map { prefs ->
+ prefs[pluginsEnabledKey] ?: true
+ }
+
+ suspend fun setPluginsEnabled(enabled: Boolean) {
+ dataStore.edit { prefs ->
+ prefs[pluginsEnabledKey] = enabled
+ }
+ }
+
+ // Scraper code storage
+ fun getScraperCodeFile(scraperId: String): File {
+ return File(codeDir, "$scraperId.js")
+ }
+
+ fun saveScraperCode(scraperId: String, code: String) {
+ getScraperCodeFile(scraperId).writeText(code)
+ }
+
+ fun getScraperCode(scraperId: String): String? {
+ val file = getScraperCodeFile(scraperId)
+ return if (file.exists()) file.readText() else null
+ }
+
+ fun deleteScraperCode(scraperId: String) {
+ getScraperCodeFile(scraperId).delete()
+ }
+
+ fun clearAllScraperCode() {
+ codeDir.listFiles()?.forEach { it.delete() }
+ }
+
+ // Per-scraper settings
+ suspend fun getScraperSettings(scraperId: String): Map {
+ val prefs = dataStore.data.first()
+ val allSettings = prefs[scraperSettingsKey]?.let { json ->
+ try {
+ @Suppress("UNCHECKED_CAST")
+ moshi.adapter>>(settingsMapType).fromJson(json) ?: emptyMap()
+ } catch (e: Exception) {
+ emptyMap()
+ }
+ } ?: emptyMap()
+
+ @Suppress("UNCHECKED_CAST")
+ return allSettings[scraperId] as? Map ?: emptyMap()
+ }
+
+ suspend fun setScraperSettings(scraperId: String, settings: Map) {
+ val prefs = dataStore.data.first()
+ val allSettings = prefs[scraperSettingsKey]?.let { json ->
+ try {
+ @Suppress("UNCHECKED_CAST")
+ moshi.adapter>>(settingsMapType).fromJson(json)?.toMutableMap()
+ ?: mutableMapOf()
+ } catch (e: Exception) {
+ mutableMapOf()
+ }
+ } ?: mutableMapOf()
+
+ allSettings[scraperId] = settings
+
+ val json = moshi.adapter>>(settingsMapType).toJson(allSettings)
+ dataStore.edit { p ->
+ p[scraperSettingsKey] = json
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/local/TmdbSettingsDataStore.kt b/app/src/main/java/com/nuvio/tv/data/local/TmdbSettingsDataStore.kt
new file mode 100644
index 00000000..7126cb3f
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/local/TmdbSettingsDataStore.kt
@@ -0,0 +1,77 @@
+package com.nuvio.tv.data.local
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.preferencesDataStore
+import com.nuvio.tv.domain.model.TmdbSettings
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.tmdbSettingsDataStore: DataStore by preferencesDataStore(name = "tmdb_settings")
+
+@Singleton
+class TmdbSettingsDataStore @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private val dataStore = context.tmdbSettingsDataStore
+
+ private val enabledKey = booleanPreferencesKey("tmdb_enabled")
+ private val useArtworkKey = booleanPreferencesKey("tmdb_use_artwork")
+ private val useBasicInfoKey = booleanPreferencesKey("tmdb_use_basic_info")
+ private val useDetailsKey = booleanPreferencesKey("tmdb_use_details")
+ private val useCreditsKey = booleanPreferencesKey("tmdb_use_credits")
+ private val useProductionsKey = booleanPreferencesKey("tmdb_use_productions")
+ private val useNetworksKey = booleanPreferencesKey("tmdb_use_networks")
+ private val useEpisodesKey = booleanPreferencesKey("tmdb_use_episodes")
+
+ val settings: Flow = dataStore.data.map { prefs ->
+ TmdbSettings(
+ enabled = prefs[enabledKey] ?: true,
+ useArtwork = prefs[useArtworkKey] ?: true,
+ useBasicInfo = prefs[useBasicInfoKey] ?: true,
+ useDetails = prefs[useDetailsKey] ?: true,
+ useCredits = prefs[useCreditsKey] ?: true,
+ useProductions = prefs[useProductionsKey] ?: true,
+ useNetworks = prefs[useNetworksKey] ?: true,
+ useEpisodes = prefs[useEpisodesKey] ?: true
+ )
+ }
+
+ suspend fun setEnabled(enabled: Boolean) {
+ dataStore.edit { it[enabledKey] = enabled }
+ }
+
+ suspend fun setUseArtwork(enabled: Boolean) {
+ dataStore.edit { it[useArtworkKey] = enabled }
+ }
+
+ suspend fun setUseBasicInfo(enabled: Boolean) {
+ dataStore.edit { it[useBasicInfoKey] = enabled }
+ }
+
+ suspend fun setUseDetails(enabled: Boolean) {
+ dataStore.edit { it[useDetailsKey] = enabled }
+ }
+
+ suspend fun setUseCredits(enabled: Boolean) {
+ dataStore.edit { it[useCreditsKey] = enabled }
+ }
+
+ suspend fun setUseProductions(enabled: Boolean) {
+ dataStore.edit { it[useProductionsKey] = enabled }
+ }
+
+ suspend fun setUseNetworks(enabled: Boolean) {
+ dataStore.edit { it[useNetworksKey] = enabled }
+ }
+
+ suspend fun setUseEpisodes(enabled: Boolean) {
+ dataStore.edit { it[useEpisodesKey] = enabled }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/local/WatchProgressPreferences.kt b/app/src/main/java/com/nuvio/tv/data/local/WatchProgressPreferences.kt
new file mode 100644
index 00000000..f8b4ce1f
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/local/WatchProgressPreferences.kt
@@ -0,0 +1,195 @@
+package com.nuvio.tv.data.local
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import com.nuvio.tv.domain.model.WatchProgress
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+import javax.inject.Singleton
+
+private val Context.watchProgressDataStore: DataStore by preferencesDataStore(
+ name = "watch_progress_preferences"
+)
+
+@Singleton
+class WatchProgressPreferences @Inject constructor(
+ @ApplicationContext private val context: Context
+) {
+ private val gson = Gson()
+ private val watchProgressKey = stringPreferencesKey("watch_progress_map")
+
+ // Maximum items to keep in continue watching
+ private val maxItems = 50
+
+ /**
+ * Get all watch progress items, sorted by last watched (most recent first)
+ * For series, only returns the series-level entry (not individual episode entries)
+ * to avoid duplicates in continue watching.
+ */
+ val allProgress: Flow> = context.watchProgressDataStore.data
+ .map { preferences ->
+ val json = preferences[watchProgressKey] ?: "{}"
+ val allItems = parseProgressMap(json)
+
+ // Filter to keep only series-level entries (key = contentId) or movie entries
+ // This avoids duplicate entries where both episode-specific and series-level exist
+ allItems.entries
+ .filter { (key, progress) ->
+ // Keep movies (no season/episode) OR series-level entries (key matches contentId exactly)
+ progress.contentType == "movie" || key == progress.contentId
+ }
+ .map { it.value }
+ .sortedByDescending { it.lastWatched }
+ .toList()
+ }
+
+ /**
+ * Get items that are in progress (not completed)
+ */
+ val continueWatching: Flow> = allProgress.map { list ->
+ list.filter { it.isInProgress() }
+ }
+
+ /**
+ * Get watch progress for a specific content item
+ */
+ fun getProgress(contentId: String): Flow {
+ return context.watchProgressDataStore.data.map { preferences ->
+ val json = preferences[watchProgressKey] ?: "{}"
+ val map = parseProgressMap(json)
+ map[contentId]
+ }
+ }
+
+ /**
+ * Get watch progress for a specific episode
+ */
+ fun getEpisodeProgress(contentId: String, season: Int, episode: Int): Flow {
+ return context.watchProgressDataStore.data.map { preferences ->
+ val json = preferences[watchProgressKey] ?: "{}"
+ val map = parseProgressMap(json)
+ map.values.find {
+ it.contentId == contentId && it.season == season && it.episode == episode
+ }
+ }
+ }
+
+ /**
+ * Get all episode progress for a series
+ */
+ fun getAllEpisodeProgress(contentId: String): Flow, WatchProgress>> {
+ return context.watchProgressDataStore.data.map { preferences ->
+ val json = preferences[watchProgressKey] ?: "{}"
+ val map = parseProgressMap(json)
+ map.values
+ .filter { it.contentId == contentId && it.season != null && it.episode != null }
+ .associateBy { (it.season!! to it.episode!!) }
+ }
+ }
+
+ /**
+ * Save or update watch progress
+ */
+ suspend fun saveProgress(progress: WatchProgress) {
+ context.watchProgressDataStore.edit { preferences ->
+ val json = preferences[watchProgressKey] ?: "{}"
+ val map = parseProgressMap(json).toMutableMap()
+
+ // Use a composite key for series episodes to track each episode separately
+ val key = createKey(progress)
+ map[key] = progress
+
+ // Also update the series-level entry to track the latest episode
+ if (progress.contentType == "series" && progress.season != null && progress.episode != null) {
+ val seriesKey = progress.contentId
+ val existingSeriesProgress = map[seriesKey]
+
+ // Update series-level progress if this is a more recent watch
+ if (existingSeriesProgress == null || progress.lastWatched > existingSeriesProgress.lastWatched) {
+ map[seriesKey] = progress.copy(videoId = progress.videoId)
+ }
+ }
+
+ // Prune old items if exceeds max
+ val pruned = pruneOldItems(map)
+ preferences[watchProgressKey] = gson.toJson(pruned)
+ }
+ }
+
+ /**
+ * Remove watch progress for a specific item
+ */
+ suspend fun removeProgress(contentId: String, season: Int? = null, episode: Int? = null) {
+ context.watchProgressDataStore.edit { preferences ->
+ val json = preferences[watchProgressKey] ?: "{}"
+ val map = parseProgressMap(json).toMutableMap()
+
+ if (season != null && episode != null) {
+ // Remove specific episode progress
+ val key = "${contentId}_s${season}e${episode}"
+ map.remove(key)
+ } else {
+ // Remove all progress for this content
+ val keysToRemove = map.keys.filter { it.startsWith(contentId) }
+ keysToRemove.forEach { map.remove(it) }
+ }
+
+ preferences[watchProgressKey] = gson.toJson(map)
+ }
+ }
+
+ /**
+ * Mark content as completed
+ */
+ suspend fun markAsCompleted(progress: WatchProgress) {
+ val completedProgress = progress.copy(
+ position = progress.duration,
+ lastWatched = System.currentTimeMillis()
+ )
+ saveProgress(completedProgress)
+ }
+
+ /**
+ * Clear all watch progress
+ */
+ suspend fun clearAll() {
+ context.watchProgressDataStore.edit { preferences ->
+ preferences.remove(watchProgressKey)
+ }
+ }
+
+ private fun createKey(progress: WatchProgress): String {
+ return if (progress.season != null && progress.episode != null) {
+ "${progress.contentId}_s${progress.season}e${progress.episode}"
+ } else {
+ progress.contentId
+ }
+ }
+
+ private fun parseProgressMap(json: String): Map {
+ return try {
+ val type = object : TypeToken>() {}.type
+ gson.fromJson(json, type) ?: emptyMap()
+ } catch (e: Exception) {
+ emptyMap()
+ }
+ }
+
+ private fun pruneOldItems(map: MutableMap): Map {
+ if (map.size <= maxItems) return map
+
+ // Keep the most recently watched items
+ return map.entries
+ .sortedByDescending { it.value.lastWatched }
+ .take(maxItems)
+ .associate { it.key to it.value }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/mapper/AddonMapper.kt b/app/src/main/java/com/nuvio/tv/data/mapper/AddonMapper.kt
new file mode 100644
index 00000000..7f168dc1
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/mapper/AddonMapper.kt
@@ -0,0 +1,66 @@
+package com.nuvio.tv.data.mapper
+
+import com.nuvio.tv.data.remote.dto.AddonManifestDto
+import com.nuvio.tv.data.remote.dto.CatalogDescriptorDto
+import com.nuvio.tv.domain.model.Addon
+import com.nuvio.tv.domain.model.AddonResource
+import com.nuvio.tv.domain.model.CatalogExtra
+import com.nuvio.tv.domain.model.CatalogDescriptor
+import com.nuvio.tv.domain.model.ContentType
+
+fun AddonManifestDto.toDomain(baseUrl: String): Addon {
+ return Addon(
+ id = id,
+ name = name,
+ version = version,
+ description = description,
+ logo = logo,
+ baseUrl = baseUrl,
+ catalogs = catalogs.map { it.toDomain() },
+ types = types.map { ContentType.fromString(it) },
+ resources = parseResources(resources, types)
+ )
+}
+
+fun CatalogDescriptorDto.toDomain(): CatalogDescriptor {
+ return CatalogDescriptor(
+ type = ContentType.fromString(type),
+ id = id,
+ name = name,
+ extra = extra.orEmpty().map { dto ->
+ CatalogExtra(
+ name = dto.name,
+ isRequired = dto.isRequired ?: false,
+ options = dto.options
+ )
+ }
+ )
+}
+
+private fun parseResources(resources: List, defaultTypes: List): List {
+ return resources.mapNotNull { resource ->
+ when (resource) {
+ is String -> {
+ // Simple resource format: "meta", "stream", etc.
+ AddonResource(
+ name = resource,
+ types = defaultTypes,
+ idPrefixes = null
+ )
+ }
+ is Map<*, *> -> {
+ // Complex resource format with types and idPrefixes
+ val name = resource["name"] as? String ?: return@mapNotNull null
+ val types = (resource["types"] as? List<*>)?.filterIsInstance() ?: defaultTypes
+ val idPrefixes = (resource["idPrefixes"] as? List<*>)?.filterIsInstance()
+ AddonResource(
+ name = name,
+ types = types,
+ idPrefixes = idPrefixes
+ )
+ }
+ else -> null
+ }
+ }
+}
+
diff --git a/app/src/main/java/com/nuvio/tv/data/mapper/CatalogMapper.kt b/app/src/main/java/com/nuvio/tv/data/mapper/CatalogMapper.kt
new file mode 100644
index 00000000..f657c10a
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/mapper/CatalogMapper.kt
@@ -0,0 +1,21 @@
+package com.nuvio.tv.data.mapper
+
+import com.nuvio.tv.data.remote.dto.MetaPreviewDto
+import com.nuvio.tv.domain.model.ContentType
+import com.nuvio.tv.domain.model.MetaPreview
+import com.nuvio.tv.domain.model.PosterShape
+
+fun MetaPreviewDto.toDomain(): MetaPreview {
+ return MetaPreview(
+ id = id,
+ type = ContentType.fromString(type),
+ name = name,
+ poster = poster,
+ posterShape = PosterShape.fromString(posterShape),
+ background = background,
+ description = description,
+ releaseInfo = releaseInfo,
+ imdbRating = imdbRating?.toFloatOrNull(),
+ genres = genres ?: emptyList()
+ )
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/mapper/MetaMapper.kt b/app/src/main/java/com/nuvio/tv/data/mapper/MetaMapper.kt
new file mode 100644
index 00000000..e7519eb4
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/mapper/MetaMapper.kt
@@ -0,0 +1,85 @@
+package com.nuvio.tv.data.mapper
+
+import com.nuvio.tv.data.remote.dto.MetaDto
+import com.nuvio.tv.data.remote.dto.MetaLinkDto
+import com.nuvio.tv.data.remote.dto.VideoDto
+import com.nuvio.tv.domain.model.ContentType
+import com.nuvio.tv.domain.model.Meta
+import com.nuvio.tv.domain.model.MetaCastMember
+import com.nuvio.tv.domain.model.MetaLink
+import com.nuvio.tv.domain.model.PosterShape
+import com.nuvio.tv.domain.model.Video
+
+fun MetaDto.toDomain(): Meta {
+ return Meta(
+ id = id,
+ type = ContentType.fromString(type),
+ name = name,
+ poster = poster,
+ posterShape = PosterShape.fromString(posterShape),
+ background = background,
+ logo = logo,
+ description = description,
+ releaseInfo = releaseInfo,
+ imdbRating = imdbRating?.toFloatOrNull(),
+ genres = genres ?: emptyList(),
+ runtime = runtime,
+ director = coerceStringList(director),
+ writer = coerceStringList(writer).ifEmpty { coerceStringList(writers) },
+ cast = coerceStringList(cast),
+ castMembers = appExtras?.cast
+ .orEmpty()
+ .mapNotNull { castMember ->
+ val name = castMember.name.trim()
+ if (name.isBlank()) return@mapNotNull null
+ MetaCastMember(
+ name = name,
+ character = castMember.character?.takeIf { it.isNotBlank() },
+ photo = castMember.photo?.takeIf { it.isNotBlank() }
+ )
+ },
+ videos = videos?.map { it.toDomain() } ?: emptyList(),
+ productionCompanies = emptyList(),
+ networks = emptyList(),
+ country = country,
+ awards = awards,
+ language = language,
+ links = links?.mapNotNull { it.toDomain() } ?: emptyList()
+ )
+}
+
+private fun coerceStringList(value: Any?): List {
+ return when (value) {
+ null -> emptyList()
+ is String -> listOf(value)
+ is List<*> -> value.filterIsInstance()
+ is Map<*, *> -> {
+ // Some addons may return an object; try a couple common keys.
+ val name = value["name"] as? String
+ if (!name.isNullOrBlank()) listOf(name) else emptyList()
+ }
+ else -> emptyList()
+ }
+}
+
+fun VideoDto.toDomain(): Video {
+ return Video(
+ id = id,
+ title = name ?: title ?: "Episode ${episode ?: number ?: 0}",
+ released = released,
+ thumbnail = thumbnail,
+ season = season,
+ episode = episode ?: number,
+ overview = overview ?: description
+ )
+}
+
+fun MetaLinkDto.toDomain(): MetaLink? {
+ return url?.let {
+ MetaLink(
+ name = name,
+ category = category,
+ url = it
+ )
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/mapper/StreamMapper.kt b/app/src/main/java/com/nuvio/tv/data/mapper/StreamMapper.kt
new file mode 100644
index 00000000..4e346f13
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/mapper/StreamMapper.kt
@@ -0,0 +1,34 @@
+package com.nuvio.tv.data.mapper
+
+import com.nuvio.tv.data.remote.dto.BehaviorHintsDto
+import com.nuvio.tv.data.remote.dto.ProxyHeadersDto
+import com.nuvio.tv.data.remote.dto.StreamDto
+import com.nuvio.tv.domain.model.ProxyHeaders
+import com.nuvio.tv.domain.model.Stream
+import com.nuvio.tv.domain.model.StreamBehaviorHints
+
+fun StreamDto.toDomain(addonName: String, addonLogo: String?): Stream = Stream(
+ name = name,
+ title = title,
+ description = description,
+ url = url,
+ ytId = ytId,
+ infoHash = infoHash,
+ fileIdx = fileIdx,
+ externalUrl = externalUrl,
+ behaviorHints = behaviorHints?.toDomain(),
+ addonName = addonName,
+ addonLogo = addonLogo
+)
+
+fun BehaviorHintsDto.toDomain(): StreamBehaviorHints = StreamBehaviorHints(
+ notWebReady = notWebReady,
+ bingeGroup = bingeGroup,
+ countryWhitelist = countryWhitelist,
+ proxyHeaders = proxyHeaders?.toDomain()
+)
+
+fun ProxyHeadersDto.toDomain(): ProxyHeaders = ProxyHeaders(
+ request = request,
+ response = response
+)
diff --git a/app/src/main/java/com/nuvio/tv/data/remote/api/AddonApi.kt b/app/src/main/java/com/nuvio/tv/data/remote/api/AddonApi.kt
new file mode 100644
index 00000000..7c669d7d
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/remote/api/AddonApi.kt
@@ -0,0 +1,24 @@
+package com.nuvio.tv.data.remote.api
+
+import com.nuvio.tv.data.remote.dto.AddonManifestDto
+import com.nuvio.tv.data.remote.dto.CatalogResponseDto
+import com.nuvio.tv.data.remote.dto.MetaResponseDto
+import com.nuvio.tv.data.remote.dto.StreamResponseDto
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Url
+
+interface AddonApi {
+
+ @GET
+ suspend fun getManifest(@Url manifestUrl: String): Response
+
+ @GET
+ suspend fun getCatalog(@Url catalogUrl: String): Response
+
+ @GET
+ suspend fun getMeta(@Url metaUrl: String): Response
+
+ @GET
+ suspend fun getStreams(@Url streamUrl: String): Response
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt b/app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt
new file mode 100644
index 00000000..6461ea32
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt
@@ -0,0 +1,190 @@
+package com.nuvio.tv.data.remote.api
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Path
+import retrofit2.http.Query
+
+interface TmdbApi {
+
+ @GET("find/{external_id}")
+ suspend fun findByExternalId(
+ @Path("external_id") externalId: String,
+ @Query("api_key") apiKey: String,
+ @Query("external_source") externalSource: String = "imdb_id"
+ ): Response
+
+ @GET("movie/{movie_id}/external_ids")
+ suspend fun getMovieExternalIds(
+ @Path("movie_id") movieId: Int,
+ @Query("api_key") apiKey: String
+ ): Response
+
+ @GET("tv/{tv_id}/external_ids")
+ suspend fun getTvExternalIds(
+ @Path("tv_id") tvId: Int,
+ @Query("api_key") apiKey: String
+ ): Response
+
+ @GET("movie/{movie_id}")
+ suspend fun getMovieDetails(
+ @Path("movie_id") movieId: Int,
+ @Query("api_key") apiKey: String
+ ): Response
+
+ @GET("tv/{tv_id}")
+ suspend fun getTvDetails(
+ @Path("tv_id") tvId: Int,
+ @Query("api_key") apiKey: String
+ ): Response
+
+ @GET("movie/{movie_id}/credits")
+ suspend fun getMovieCredits(
+ @Path("movie_id") movieId: Int,
+ @Query("api_key") apiKey: String
+ ): Response
+
+ @GET("tv/{tv_id}/credits")
+ suspend fun getTvCredits(
+ @Path("tv_id") tvId: Int,
+ @Query("api_key") apiKey: String
+ ): Response
+
+ @GET("movie/{movie_id}/images")
+ suspend fun getMovieImages(
+ @Path("movie_id") movieId: Int,
+ @Query("api_key") apiKey: String,
+ @Query("include_image_language") includeImageLanguage: String = "en,null"
+ ): Response
+
+ @GET("tv/{tv_id}/images")
+ suspend fun getTvImages(
+ @Path("tv_id") tvId: Int,
+ @Query("api_key") apiKey: String,
+ @Query("include_image_language") includeImageLanguage: String = "en,null"
+ ): Response
+
+ @GET("tv/{tv_id}/season/{season_number}")
+ suspend fun getTvSeasonDetails(
+ @Path("tv_id") tvId: Int,
+ @Path("season_number") seasonNumber: Int,
+ @Query("api_key") apiKey: String
+ ): Response
+}
+
+@JsonClass(generateAdapter = true)
+data class TmdbFindResponse(
+ @Json(name = "movie_results") val movieResults: List? = null,
+ @Json(name = "tv_results") val tvResults: List? = null,
+ @Json(name = "tv_episode_results") val tvEpisodeResults: List? = null,
+ @Json(name = "tv_season_results") val tvSeasonResults: List? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbFindResult(
+ @Json(name = "id") val id: Int,
+ @Json(name = "title") val title: String? = null,
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "media_type") val mediaType: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbExternalIdsResponse(
+ @Json(name = "id") val id: Int,
+ @Json(name = "imdb_id") val imdbId: String? = null,
+ @Json(name = "tvdb_id") val tvdbId: Int? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbDetailsResponse(
+ @Json(name = "id") val id: Int,
+ @Json(name = "title") val title: String? = null,
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "overview") val overview: String? = null,
+ @Json(name = "genres") val genres: List? = null,
+ @Json(name = "release_date") val releaseDate: String? = null,
+ @Json(name = "first_air_date") val firstAirDate: String? = null,
+ @Json(name = "runtime") val runtime: Int? = null,
+ @Json(name = "episode_run_time") val episodeRunTime: List? = null,
+ @Json(name = "vote_average") val voteAverage: Double? = null,
+ @Json(name = "production_companies") val productionCompanies: List? = null,
+ @Json(name = "networks") val networks: List? = null,
+ @Json(name = "production_countries") val productionCountries: List? = null,
+ @Json(name = "origin_country") val originCountry: List? = null,
+ @Json(name = "original_language") val originalLanguage: String? = null,
+ @Json(name = "backdrop_path") val backdropPath: String? = null,
+ @Json(name = "poster_path") val posterPath: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbGenre(
+ @Json(name = "id") val id: Int,
+ @Json(name = "name") val name: String
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbCompany(
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "logo_path") val logoPath: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbNetwork(
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "logo_path") val logoPath: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbCreditsResponse(
+ @Json(name = "cast") val cast: List? = null,
+ @Json(name = "crew") val crew: List? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbCastMember(
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "character") val character: String? = null,
+ @Json(name = "profile_path") val profilePath: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbCrewMember(
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "job") val job: String? = null,
+ @Json(name = "department") val department: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbImagesResponse(
+ @Json(name = "logos") val logos: List? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbImage(
+ @Json(name = "file_path") val filePath: String? = null,
+ @Json(name = "iso_639_1") val iso6391: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbCountry(
+ @Json(name = "iso_3166_1") val iso31661: String? = null,
+ @Json(name = "name") val name: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbSeasonResponse(
+ @Json(name = "season_number") val seasonNumber: Int? = null,
+ @Json(name = "episodes") val episodes: List? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class TmdbEpisode(
+ @Json(name = "episode_number") val episodeNumber: Int? = null,
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "overview") val overview: String? = null,
+ @Json(name = "still_path") val stillPath: String? = null,
+ @Json(name = "air_date") val airDate: String? = null,
+ @Json(name = "runtime") val runtime: Int? = null
+)
diff --git a/app/src/main/java/com/nuvio/tv/data/remote/dto/AddonManifestDto.kt b/app/src/main/java/com/nuvio/tv/data/remote/dto/AddonManifestDto.kt
new file mode 100644
index 00000000..f6644204
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/remote/dto/AddonManifestDto.kt
@@ -0,0 +1,32 @@
+package com.nuvio.tv.data.remote.dto
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class AddonManifestDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "name") val name: String,
+ @Json(name = "version") val version: String,
+ @Json(name = "description") val description: String? = null,
+ @Json(name = "logo") val logo: String? = null,
+ @Json(name = "background") val background: String? = null,
+ @Json(name = "catalogs") val catalogs: List = emptyList(),
+ @Json(name = "resources") val resources: List = emptyList(),
+ @Json(name = "types") val types: List = emptyList()
+)
+
+@JsonClass(generateAdapter = true)
+data class CatalogDescriptorDto(
+ @Json(name = "type") val type: String,
+ @Json(name = "id") val id: String,
+ @Json(name = "name") val name: String,
+ @Json(name = "extra") val extra: List? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class ExtraDto(
+ @Json(name = "name") val name: String,
+ @Json(name = "isRequired") val isRequired: Boolean? = false,
+ @Json(name = "options") val options: List? = null
+)
diff --git a/app/src/main/java/com/nuvio/tv/data/remote/dto/CatalogResponseDto.kt b/app/src/main/java/com/nuvio/tv/data/remote/dto/CatalogResponseDto.kt
new file mode 100644
index 00000000..cfc4284a
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/remote/dto/CatalogResponseDto.kt
@@ -0,0 +1,25 @@
+package com.nuvio.tv.data.remote.dto
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class CatalogResponseDto(
+ @Json(name = "metas") val metas: List = emptyList()
+)
+
+@JsonClass(generateAdapter = true)
+data class MetaPreviewDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "type") val type: String,
+ @Json(name = "name") val name: String,
+ @Json(name = "poster") val poster: String? = null,
+ @Json(name = "posterShape") val posterShape: String? = null,
+ @Json(name = "background") val background: String? = null,
+ @Json(name = "logo") val logo: String? = null,
+ @Json(name = "description") val description: String? = null,
+ @Json(name = "releaseInfo") val releaseInfo: String? = null,
+ @Json(name = "imdbRating") val imdbRating: String? = null,
+ @Json(name = "genres") val genres: List? = null,
+ @Json(name = "runtime") val runtime: String? = null
+)
diff --git a/app/src/main/java/com/nuvio/tv/data/remote/dto/MetaResponseDto.kt b/app/src/main/java/com/nuvio/tv/data/remote/dto/MetaResponseDto.kt
new file mode 100644
index 00000000..43786622
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/remote/dto/MetaResponseDto.kt
@@ -0,0 +1,70 @@
+package com.nuvio.tv.data.remote.dto
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class MetaResponseDto(
+ @Json(name = "meta") val meta: MetaDto? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class MetaDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "type") val type: String,
+ @Json(name = "name") val name: String,
+ @Json(name = "poster") val poster: String? = null,
+ @Json(name = "posterShape") val posterShape: String? = null,
+ @Json(name = "background") val background: String? = null,
+ @Json(name = "logo") val logo: String? = null,
+ @Json(name = "description") val description: String? = null,
+ @Json(name = "releaseInfo") val releaseInfo: String? = null,
+ @Json(name = "imdbRating") val imdbRating: String? = null,
+ @Json(name = "genres") val genres: List? = null,
+ @Json(name = "runtime") val runtime: String? = null,
+ // Stremio addons are inconsistent here (string vs list). Keep it tolerant.
+ @Json(name = "director") val director: Any? = null,
+ // Addons are inconsistent: may be `writer` (string/list) or `writers`.
+ @Json(name = "writer") val writer: Any? = null,
+ @Json(name = "writers") val writers: Any? = null,
+ @Json(name = "cast") val cast: Any? = null,
+ @Json(name = "videos") val videos: List? = null,
+ @Json(name = "country") val country: String? = null,
+ @Json(name = "awards") val awards: String? = null,
+ @Json(name = "language") val language: String? = null,
+ @Json(name = "links") val links: List? = null,
+ @Json(name = "app_extras") val appExtras: AppExtrasDto? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class AppExtrasDto(
+ @Json(name = "cast") val cast: List? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class AppExtrasCastMemberDto(
+ @Json(name = "name") val name: String,
+ @Json(name = "character") val character: String? = null,
+ @Json(name = "photo") val photo: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class VideoDto(
+ @Json(name = "id") val id: String,
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "title") val title: String? = null,
+ @Json(name = "released") val released: String? = null,
+ @Json(name = "thumbnail") val thumbnail: String? = null,
+ @Json(name = "season") val season: Int? = null,
+ @Json(name = "episode") val episode: Int? = null,
+ @Json(name = "number") val number: Int? = null,
+ @Json(name = "overview") val overview: String? = null,
+ @Json(name = "description") val description: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class MetaLinkDto(
+ @Json(name = "name") val name: String,
+ @Json(name = "category") val category: String,
+ @Json(name = "url") val url: String? = null
+)
diff --git a/app/src/main/java/com/nuvio/tv/data/remote/dto/StreamResponseDto.kt b/app/src/main/java/com/nuvio/tv/data/remote/dto/StreamResponseDto.kt
new file mode 100644
index 00000000..2c595edb
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/remote/dto/StreamResponseDto.kt
@@ -0,0 +1,48 @@
+package com.nuvio.tv.data.remote.dto
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+data class StreamResponseDto(
+ @Json(name = "streams") val streams: List? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class StreamDto(
+ @Json(name = "name") val name: String? = null,
+ @Json(name = "title") val title: String? = null,
+ @Json(name = "description") val description: String? = null,
+ @Json(name = "url") val url: String? = null,
+ @Json(name = "ytId") val ytId: String? = null,
+ @Json(name = "infoHash") val infoHash: String? = null,
+ @Json(name = "fileIdx") val fileIdx: Int? = null,
+ @Json(name = "externalUrl") val externalUrl: String? = null,
+ @Json(name = "behaviorHints") val behaviorHints: BehaviorHintsDto? = null,
+ @Json(name = "sources") val sources: List? = null,
+ @Json(name = "subtitles") val subtitles: List? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class BehaviorHintsDto(
+ @Json(name = "notWebReady") val notWebReady: Boolean? = null,
+ @Json(name = "bingeGroup") val bingeGroup: String? = null,
+ @Json(name = "countryWhitelist") val countryWhitelist: List? = null,
+ @Json(name = "proxyHeaders") val proxyHeaders: ProxyHeadersDto? = null,
+ @Json(name = "videoHash") val videoHash: String? = null,
+ @Json(name = "videoSize") val videoSize: Long? = null,
+ @Json(name = "filename") val filename: String? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class ProxyHeadersDto(
+ @Json(name = "request") val request: Map? = null,
+ @Json(name = "response") val response: Map? = null
+)
+
+@JsonClass(generateAdapter = true)
+data class SubtitleDto(
+ @Json(name = "id") val id: String? = null,
+ @Json(name = "url") val url: String,
+ @Json(name = "lang") val lang: String
+)
diff --git a/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt
new file mode 100644
index 00000000..8dd36797
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt
@@ -0,0 +1,53 @@
+package com.nuvio.tv.data.repository
+
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.core.network.safeApiCall
+import com.nuvio.tv.data.local.AddonPreferences
+import com.nuvio.tv.data.mapper.toDomain
+import com.nuvio.tv.data.remote.api.AddonApi
+import com.nuvio.tv.domain.model.Addon
+import com.nuvio.tv.domain.repository.AddonRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+class AddonRepositoryImpl @Inject constructor(
+ private val api: AddonApi,
+ private val preferences: AddonPreferences
+) : AddonRepository {
+
+ override fun getInstalledAddons(): Flow> =
+ preferences.installedAddonUrls.map { urls ->
+ val addons = mutableListOf()
+
+ for (url in urls) {
+ when (val result = fetchAddon(url)) {
+ is NetworkResult.Success -> addons.add(result.data)
+ else -> { /* Skip failed addons */ }
+ }
+ }
+
+ addons
+ }
+
+ override suspend fun fetchAddon(baseUrl: String): NetworkResult {
+ val cleanBaseUrl = baseUrl.trimEnd('/')
+ val manifestUrl = "$cleanBaseUrl/manifest.json"
+
+ return when (val result = safeApiCall { api.getManifest(manifestUrl) }) {
+ is NetworkResult.Success -> {
+ NetworkResult.Success(result.data.toDomain(cleanBaseUrl))
+ }
+ is NetworkResult.Error -> result
+ NetworkResult.Loading -> NetworkResult.Loading
+ }
+ }
+
+ override suspend fun addAddon(url: String) {
+ preferences.addAddon(url)
+ }
+
+ override suspend fun removeAddon(url: String) {
+ preferences.removeAddon(url)
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/repository/CatalogRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/CatalogRepositoryImpl.kt
new file mode 100644
index 00000000..0d1d232b
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/repository/CatalogRepositoryImpl.kt
@@ -0,0 +1,90 @@
+package com.nuvio.tv.data.repository
+
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.core.network.safeApiCall
+import com.nuvio.tv.data.mapper.toDomain
+import com.nuvio.tv.data.remote.api.AddonApi
+import com.nuvio.tv.domain.model.CatalogRow
+import com.nuvio.tv.domain.model.ContentType
+import com.nuvio.tv.domain.repository.CatalogRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import java.net.URLEncoder
+import javax.inject.Inject
+
+class CatalogRepositoryImpl @Inject constructor(
+ private val api: AddonApi
+) : CatalogRepository {
+
+ override fun getCatalog(
+ addonBaseUrl: String,
+ addonId: String,
+ addonName: String,
+ catalogId: String,
+ catalogName: String,
+ type: String,
+ skip: Int,
+ extraArgs: Map
+ ): Flow> = flow {
+ emit(NetworkResult.Loading)
+
+ val url = buildCatalogUrl(addonBaseUrl, type, catalogId, skip, extraArgs)
+
+ when (val result = safeApiCall { api.getCatalog(url) }) {
+ is NetworkResult.Success -> {
+ val items = result.data.metas.map { it.toDomain() }
+ val catalogRow = CatalogRow(
+ addonId = addonId,
+ addonName = addonName,
+ addonBaseUrl = addonBaseUrl,
+ catalogId = catalogId,
+ catalogName = catalogName,
+ type = ContentType.fromString(type),
+ items = items,
+ isLoading = false,
+ hasMore = items.size >= 100,
+ currentPage = skip / 100
+ )
+ emit(NetworkResult.Success(catalogRow))
+ }
+ is NetworkResult.Error -> emit(result)
+ NetworkResult.Loading -> { /* Already emitted */ }
+ }
+ }
+
+ private fun buildCatalogUrl(
+ baseUrl: String,
+ type: String,
+ catalogId: String,
+ skip: Int,
+ extraArgs: Map
+ ): String {
+ val cleanBaseUrl = baseUrl.trimEnd('/')
+
+ if (extraArgs.isEmpty()) {
+ return if (skip > 0) {
+ "$cleanBaseUrl/catalog/$type/$catalogId/skip=$skip.json"
+ } else {
+ "$cleanBaseUrl/catalog/$type/$catalogId.json"
+ }
+ }
+
+ val allArgs = LinkedHashMap()
+ allArgs.putAll(extraArgs)
+
+ // For Stremio catalogs, pagination is controlled by `skip` inside extraArgs.
+ if (!allArgs.containsKey("skip") && (skip > 0 || allArgs.containsKey("search"))) {
+ allArgs["skip"] = skip.toString()
+ }
+
+ val encodedArgs = allArgs.entries.joinToString("&") { (key, value) ->
+ "${encodeArg(key)}=${encodeArg(value)}"
+ }
+
+ return "$cleanBaseUrl/catalog/$type/$catalogId/$encodedArgs.json"
+ }
+
+ private fun encodeArg(value: String): String {
+ return URLEncoder.encode(value, "UTF-8").replace("+", "%20")
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/repository/MetaRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/MetaRepositoryImpl.kt
new file mode 100644
index 00000000..f61e343f
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/repository/MetaRepositoryImpl.kt
@@ -0,0 +1,106 @@
+package com.nuvio.tv.data.repository
+
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.core.network.safeApiCall
+import com.nuvio.tv.data.mapper.toDomain
+import com.nuvio.tv.data.remote.api.AddonApi
+import com.nuvio.tv.domain.model.Meta
+import com.nuvio.tv.domain.repository.AddonRepository
+import com.nuvio.tv.domain.repository.MetaRepository
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import javax.inject.Inject
+
+class MetaRepositoryImpl @Inject constructor(
+ private val api: AddonApi,
+ private val addonRepository: AddonRepository
+) : MetaRepository {
+
+ override fun getMeta(
+ addonBaseUrl: String,
+ type: String,
+ id: String
+ ): Flow> = flow {
+ emit(NetworkResult.Loading)
+
+ val url = buildMetaUrl(addonBaseUrl, type, id)
+
+ when (val result = safeApiCall { api.getMeta(url) }) {
+ is NetworkResult.Success -> {
+ val metaDto = result.data.meta
+ if (metaDto != null) {
+ emit(NetworkResult.Success(metaDto.toDomain()))
+ } else {
+ emit(NetworkResult.Error("Meta not found"))
+ }
+ }
+ is NetworkResult.Error -> emit(result)
+ NetworkResult.Loading -> { /* Already emitted */ }
+ }
+ }
+
+ override fun getMetaFromAllAddons(
+ type: String,
+ id: String
+ ): Flow> = flow {
+ emit(NetworkResult.Loading)
+
+ val addons = addonRepository.getInstalledAddons().first()
+
+ // Find addons that support meta resource for this type
+ // Resources can be simple strings like "meta" or objects with name/types
+ val metaAddons = addons.filter { addon ->
+ addon.resources.any { resource ->
+ resource.name == "meta" &&
+ (resource.types.isEmpty() || resource.types.contains(type))
+ }
+ }
+
+ if (metaAddons.isEmpty()) {
+ // Fallback: try all addons that have the type in their supported types
+ val fallbackAddons = addons.filter { addon ->
+ addon.types.any { it.toApiString() == type }
+ }
+
+ for (addon in fallbackAddons) {
+ val url = buildMetaUrl(addon.baseUrl, type, id)
+ when (val result = safeApiCall { api.getMeta(url) }) {
+ is NetworkResult.Success -> {
+ val metaDto = result.data.meta
+ if (metaDto != null) {
+ emit(NetworkResult.Success(metaDto.toDomain()))
+ return@flow
+ }
+ }
+ else -> { /* Try next addon */ }
+ }
+ }
+
+ emit(NetworkResult.Error("No addons support meta for type: $type"))
+ return@flow
+ }
+
+ // Try each addon until we find meta
+ for (addon in metaAddons) {
+ val url = buildMetaUrl(addon.baseUrl, type, id)
+ when (val result = safeApiCall { api.getMeta(url) }) {
+ is NetworkResult.Success -> {
+ val metaDto = result.data.meta
+ if (metaDto != null) {
+ emit(NetworkResult.Success(metaDto.toDomain()))
+ return@flow
+ }
+ }
+ else -> { /* Try next addon */ }
+ }
+ }
+
+ emit(NetworkResult.Error("Meta not found in any addon"))
+ }
+
+ private fun buildMetaUrl(baseUrl: String, type: String, id: String): String {
+ val cleanBaseUrl = baseUrl.trimEnd('/')
+ return "$cleanBaseUrl/meta/$type/$id.json"
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/repository/StreamRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/StreamRepositoryImpl.kt
new file mode 100644
index 00000000..4bb13e14
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/repository/StreamRepositoryImpl.kt
@@ -0,0 +1,281 @@
+package com.nuvio.tv.data.repository
+
+import android.util.Log
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.core.network.safeApiCall
+import com.nuvio.tv.core.plugin.PluginManager
+import com.nuvio.tv.core.tmdb.TmdbService
+import com.nuvio.tv.data.mapper.toDomain
+import com.nuvio.tv.data.remote.api.AddonApi
+import com.nuvio.tv.domain.model.Addon
+import com.nuvio.tv.domain.model.AddonStreams
+import com.nuvio.tv.domain.model.ProxyHeaders
+import com.nuvio.tv.domain.model.Stream
+import com.nuvio.tv.domain.model.StreamBehaviorHints
+import com.nuvio.tv.domain.repository.AddonRepository
+import com.nuvio.tv.domain.repository.StreamRepository
+import kotlinx.coroutines.async
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+private const val TAG = "StreamRepositoryImpl"
+
+class StreamRepositoryImpl @Inject constructor(
+ private val api: AddonApi,
+ private val addonRepository: AddonRepository,
+ private val pluginManager: PluginManager,
+ private val tmdbService: TmdbService
+) : StreamRepository {
+
+ override fun getStreamsFromAllAddons(
+ type: String,
+ videoId: String,
+ season: Int?,
+ episode: Int?
+ ): Flow>> = flow {
+ emit(NetworkResult.Loading)
+
+ try {
+ val addons = addonRepository.getInstalledAddons().first()
+
+ // Filter addons that support streams for this type
+ val streamAddons = addons.filter { addon ->
+ addon.supportsStreamResource(type)
+ }
+
+ // Convert IMDB ID to TMDB ID if needed for plugins
+ val tmdbId = tmdbService.ensureTmdbId(videoId, type)
+ Log.d(TAG, "Video ID: $videoId -> TMDB ID: $tmdbId (type: $type)")
+
+ // Accumulate results as they arrive
+ val accumulatedResults = mutableListOf()
+
+ coroutineScope {
+ // Channel to receive results as they complete
+ val resultChannel = Channel(Channel.UNLIMITED)
+
+ // Track number of pending jobs
+ val totalJobs = streamAddons.size + (if (tmdbId != null) 1 else 0)
+ var completedJobs = 0
+
+ // Launch addon jobs
+ streamAddons.forEach { addon ->
+ launch {
+ try {
+ val streamsResult = getStreamsFromAddon(addon.baseUrl, type, videoId)
+ when (streamsResult) {
+ is NetworkResult.Success -> {
+ if (streamsResult.data.isNotEmpty()) {
+ resultChannel.send(
+ AddonStreams(
+ addonName = addon.name,
+ addonLogo = addon.logo,
+ streams = streamsResult.data
+ )
+ )
+ }
+ }
+ else -> { /* No streams */ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Addon ${addon.name} failed: ${e.message}")
+ } finally {
+ completedJobs++
+ if (completedJobs >= totalJobs) {
+ resultChannel.close()
+ }
+ }
+ }
+ }
+
+ // Launch plugin jobs if we have TMDB ID - each scraper sends its own result
+ if (tmdbId != null) {
+ launch {
+ try {
+ // Stream plugins individually
+ streamLocalPlugins(tmdbId, type, season, episode, resultChannel) {
+ completedJobs++
+ if (completedJobs >= totalJobs) {
+ resultChannel.close()
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Plugin execution failed: ${e.message}")
+ completedJobs++
+ if (completedJobs >= totalJobs) {
+ resultChannel.close()
+ }
+ }
+ }
+ }
+
+ // Handle case where there are no jobs
+ if (totalJobs == 0) {
+ resultChannel.close()
+ }
+
+ // Emit results as they arrive
+ for (result in resultChannel) {
+ accumulatedResults.add(result)
+ emit(NetworkResult.Success(accumulatedResults.toList()))
+ Log.d(TAG, "Emitted ${accumulatedResults.size} addon(s), latest: ${result.addonName} with ${result.streams.size} streams")
+ }
+ }
+
+ // Emit final result (even if empty)
+ if (accumulatedResults.isEmpty()) {
+ emit(NetworkResult.Success(emptyList()))
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to fetch streams: ${e.message}", e)
+ emit(NetworkResult.Error(e.message ?: "Failed to fetch streams"))
+ }
+ }
+
+ /**
+ * Stream local plugin results - each scraper sends results individually
+ */
+ private suspend fun streamLocalPlugins(
+ tmdbId: String,
+ type: String,
+ season: Int?,
+ episode: Int?,
+ resultChannel: Channel,
+ onComplete: () -> Unit
+ ) {
+ // Check if plugins are enabled
+ if (!pluginManager.pluginsEnabled.first()) {
+ Log.d(TAG, "Plugins are disabled")
+ onComplete()
+ return
+ }
+
+ // Normalize media type for plugins
+ val mediaType = when (type.lowercase()) {
+ "series", "tv", "show" -> "tv"
+ else -> type.lowercase()
+ }
+
+ Log.d(TAG, "Streaming plugins for TMDB: $tmdbId, type: $mediaType")
+
+ try {
+ // Collect streaming results from each scraper
+ pluginManager.executeScrapersStreaming(
+ tmdbId = tmdbId,
+ mediaType = mediaType,
+ season = season,
+ episode = episode
+ ).collect { (scraperName, results) ->
+ if (results.isNotEmpty()) {
+ val addonStreams = AddonStreams(
+ addonName = scraperName,
+ addonLogo = null,
+ streams = results.map { result ->
+ val baseTitle = result.title.takeIf { it.isNotBlank() }
+ val baseName = result.name?.takeIf { it.isNotBlank() }
+ val quality = result.quality?.takeIf { it.isNotBlank() }
+
+ val displayTitle = buildString {
+ append(baseTitle ?: baseName ?: scraperName)
+ if (!quality.isNullOrBlank() && !(baseTitle ?: "").contains(quality)) {
+ append(" ").append(quality)
+ }
+ }.takeIf { it.isNotBlank() }
+
+ val displayName = buildString {
+ append(baseName ?: baseTitle ?: scraperName)
+ if (!quality.isNullOrBlank() && !(baseName ?: "").contains(quality)) {
+ append(" - ").append(quality)
+ }
+ }.takeIf { it.isNotBlank() }
+
+ Stream(
+ name = displayName,
+ title = displayTitle,
+ url = result.url,
+ addonName = scraperName,
+ addonLogo = null,
+ description = buildDescription(result),
+ behaviorHints = result.headers?.let { headers ->
+ StreamBehaviorHints(
+ notWebReady = null,
+ bingeGroup = null,
+ countryWhitelist = null,
+ proxyHeaders = ProxyHeaders(request = headers, response = null)
+ )
+ },
+ infoHash = result.infoHash,
+ fileIdx = null,
+ ytId = null,
+ externalUrl = null
+ )
+ }
+ )
+ resultChannel.send(addonStreams)
+ Log.d(TAG, "Streamed ${results.size} results from $scraperName")
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to stream plugins: ${e.message}", e)
+ } finally {
+ onComplete()
+ }
+ }
+
+ /**
+ * Build a description string from scraper result
+ */
+ private fun buildDescription(result: com.nuvio.tv.domain.model.LocalScraperResult): String? {
+ val parts = mutableListOf()
+ result.quality?.let { parts.add(it) }
+ result.size?.let { parts.add(it) }
+ result.language?.let { parts.add(it) }
+ return if (parts.isNotEmpty()) parts.joinToString(" • ") else null
+ }
+
+ override suspend fun getStreamsFromAddon(
+ baseUrl: String,
+ type: String,
+ videoId: String
+ ): NetworkResult> {
+ val cleanBaseUrl = baseUrl.trimEnd('/')
+ val streamUrl = "$cleanBaseUrl/stream/$type/$videoId.json"
+
+ // First, get addon info for name and logo
+ val addonResult = addonRepository.fetchAddon(baseUrl)
+ val addonName = when (addonResult) {
+ is NetworkResult.Success -> addonResult.data.name
+ else -> "Unknown"
+ }
+ val addonLogo = when (addonResult) {
+ is NetworkResult.Success -> addonResult.data.logo
+ else -> null
+ }
+
+ return when (val result = safeApiCall { api.getStreams(streamUrl) }) {
+ is NetworkResult.Success -> {
+ val streams = result.data.streams?.map {
+ it.toDomain(addonName, addonLogo)
+ } ?: emptyList()
+ NetworkResult.Success(streams)
+ }
+ is NetworkResult.Error -> result
+ NetworkResult.Loading -> NetworkResult.Loading
+ }
+ }
+
+ /**
+ * Check if addon supports stream resource for the given type
+ */
+ private fun Addon.supportsStreamResource(type: String): Boolean {
+ return resources.any { resource ->
+ resource.name == "stream" &&
+ (resource.types.isEmpty() || resource.types.contains(type))
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt
new file mode 100644
index 00000000..cf83b433
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt
@@ -0,0 +1,48 @@
+package com.nuvio.tv.data.repository
+
+import com.nuvio.tv.data.local.WatchProgressPreferences
+import com.nuvio.tv.domain.model.WatchProgress
+import com.nuvio.tv.domain.repository.WatchProgressRepository
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WatchProgressRepositoryImpl @Inject constructor(
+ private val watchProgressPreferences: WatchProgressPreferences
+) : WatchProgressRepository {
+
+ override val allProgress: Flow>
+ get() = watchProgressPreferences.allProgress
+
+ override val continueWatching: Flow>
+ get() = watchProgressPreferences.continueWatching
+
+ override fun getProgress(contentId: String): Flow {
+ return watchProgressPreferences.getProgress(contentId)
+ }
+
+ override fun getEpisodeProgress(contentId: String, season: Int, episode: Int): Flow {
+ return watchProgressPreferences.getEpisodeProgress(contentId, season, episode)
+ }
+
+ override fun getAllEpisodeProgress(contentId: String): Flow, WatchProgress>> {
+ return watchProgressPreferences.getAllEpisodeProgress(contentId)
+ }
+
+ override suspend fun saveProgress(progress: WatchProgress) {
+ watchProgressPreferences.saveProgress(progress)
+ }
+
+ override suspend fun removeProgress(contentId: String, season: Int?, episode: Int?) {
+ watchProgressPreferences.removeProgress(contentId, season, episode)
+ }
+
+ override suspend fun markAsCompleted(progress: WatchProgress) {
+ watchProgressPreferences.markAsCompleted(progress)
+ }
+
+ override suspend fun clearAll() {
+ watchProgressPreferences.clearAll()
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/di/PluginModule.kt b/app/src/main/java/com/nuvio/tv/di/PluginModule.kt
new file mode 100644
index 00000000..be5e8e8a
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/di/PluginModule.kt
@@ -0,0 +1,30 @@
+package com.nuvio.tv.di
+
+import com.nuvio.tv.core.plugin.PluginManager
+import com.nuvio.tv.core.plugin.PluginRuntime
+import com.nuvio.tv.data.local.PluginDataStore
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Singleton
+
+@Module
+@InstallIn(SingletonComponent::class)
+object PluginModule {
+
+ @Provides
+ @Singleton
+ fun providePluginRuntime(): PluginRuntime {
+ return PluginRuntime()
+ }
+
+ @Provides
+ @Singleton
+ fun providePluginManager(
+ dataStore: PluginDataStore,
+ runtime: PluginRuntime
+ ): PluginManager {
+ return PluginManager(dataStore, runtime)
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/domain/model/Addon.kt b/app/src/main/java/com/nuvio/tv/domain/model/Addon.kt
new file mode 100644
index 00000000..35ca41b2
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/model/Addon.kt
@@ -0,0 +1,33 @@
+package com.nuvio.tv.domain.model
+
+data class Addon(
+ val id: String,
+ val name: String,
+ val version: String,
+ val description: String?,
+ val logo: String?,
+ val baseUrl: String,
+ val catalogs: List,
+ val types: List,
+ val resources: List
+)
+
+data class CatalogDescriptor(
+ val type: ContentType,
+ val id: String,
+ val name: String,
+ val extra: List = emptyList()
+)
+
+data class CatalogExtra(
+ val name: String,
+ val isRequired: Boolean = false,
+ val options: List? = null
+)
+
+data class AddonResource(
+ val name: String,
+ val types: List,
+ val idPrefixes: List?
+)
+
diff --git a/app/src/main/java/com/nuvio/tv/domain/model/CatalogRow.kt b/app/src/main/java/com/nuvio/tv/domain/model/CatalogRow.kt
new file mode 100644
index 00000000..b86c1d57
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/model/CatalogRow.kt
@@ -0,0 +1,14 @@
+package com.nuvio.tv.domain.model
+
+data class CatalogRow(
+ val addonId: String,
+ val addonName: String,
+ val addonBaseUrl: String,
+ val catalogId: String,
+ val catalogName: String,
+ val type: ContentType,
+ val items: List,
+ val isLoading: Boolean = false,
+ val hasMore: Boolean = true,
+ val currentPage: Int = 0
+)
diff --git a/app/src/main/java/com/nuvio/tv/domain/model/ContentType.kt b/app/src/main/java/com/nuvio/tv/domain/model/ContentType.kt
new file mode 100644
index 00000000..61b628ca
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/model/ContentType.kt
@@ -0,0 +1,27 @@
+package com.nuvio.tv.domain.model
+
+enum class ContentType {
+ MOVIE,
+ SERIES,
+ CHANNEL,
+ TV,
+ UNKNOWN;
+
+ companion object {
+ fun fromString(value: String): ContentType = when (value.lowercase()) {
+ "movie" -> MOVIE
+ "series" -> SERIES
+ "channel" -> CHANNEL
+ "tv" -> TV
+ else -> UNKNOWN
+ }
+ }
+
+ fun toApiString(): String = when (this) {
+ MOVIE -> "movie"
+ SERIES -> "series"
+ CHANNEL -> "channel"
+ TV -> "tv"
+ UNKNOWN -> "movie"
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/domain/model/Meta.kt b/app/src/main/java/com/nuvio/tv/domain/model/Meta.kt
new file mode 100644
index 00000000..7f2251a4
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/model/Meta.kt
@@ -0,0 +1,55 @@
+package com.nuvio.tv.domain.model
+
+data class Meta(
+ val id: String,
+ val type: ContentType,
+ val name: String,
+ val poster: String?,
+ val posterShape: PosterShape,
+ val background: String?,
+ val logo: String?,
+ val description: String?,
+ val releaseInfo: String?,
+ val imdbRating: Float?,
+ val genres: List,
+ val runtime: String?,
+ val director: List,
+ val writer: List = emptyList(),
+ val cast: List,
+ val castMembers: List = emptyList(),
+ val videos: List,
+ val productionCompanies: List = emptyList(),
+ val networks: List = emptyList(),
+ val country: String?,
+ val awards: String?,
+ val language: String?,
+ val links: List
+)
+
+data class MetaCastMember(
+ val name: String,
+ val character: String? = null,
+ val photo: String? = null
+)
+
+data class MetaCompany(
+ val name: String,
+ val logo: String? = null
+)
+
+data class Video(
+ val id: String,
+ val title: String,
+ val released: String?,
+ val thumbnail: String?,
+ val season: Int?,
+ val episode: Int?,
+ val overview: String?,
+ val runtime: Int? = null // episode runtime in minutes
+)
+
+data class MetaLink(
+ val name: String,
+ val category: String,
+ val url: String
+)
diff --git a/app/src/main/java/com/nuvio/tv/domain/model/MetaPreview.kt b/app/src/main/java/com/nuvio/tv/domain/model/MetaPreview.kt
new file mode 100644
index 00000000..e01a3712
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/model/MetaPreview.kt
@@ -0,0 +1,14 @@
+package com.nuvio.tv.domain.model
+
+data class MetaPreview(
+ val id: String,
+ val type: ContentType,
+ val name: String,
+ val poster: String?,
+ val posterShape: PosterShape,
+ val background: String?,
+ val description: String?,
+ val releaseInfo: String?,
+ val imdbRating: Float?,
+ val genres: List
+)
diff --git a/app/src/main/java/com/nuvio/tv/domain/model/Plugin.kt b/app/src/main/java/com/nuvio/tv/domain/model/Plugin.kt
new file mode 100644
index 00000000..03861358
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/model/Plugin.kt
@@ -0,0 +1,132 @@
+package com.nuvio.tv.domain.model
+
+import com.squareup.moshi.JsonClass
+
+/**
+ * Represents a plugin repository containing scrapers
+ */
+data class PluginRepository(
+ val id: String,
+ val name: String,
+ val url: String,
+ val description: String? = null,
+ val enabled: Boolean = true,
+ val lastUpdated: Long = 0L,
+ val scraperCount: Int = 0
+)
+
+/**
+ * Represents manifest.json from a plugin repository
+ */
+@JsonClass(generateAdapter = true)
+data class PluginManifest(
+ val name: String,
+ val version: String,
+ val description: String? = null,
+ val author: String? = null,
+ val scrapers: List
+)
+
+/**
+ * Scraper info from manifest.json
+ */
+@JsonClass(generateAdapter = true)
+data class ScraperManifestInfo(
+ val id: String,
+ val name: String,
+ val description: String? = null,
+ val version: String,
+ val filename: String,
+ val supportedTypes: List = listOf("movie", "tv"),
+ val enabled: Boolean = true,
+ val logo: String? = null,
+ val contentLanguage: List? = null,
+ val supportedPlatforms: List? = null,
+ val disabledPlatforms: List? = null,
+ val formats: List? = null,
+ val supportedFormats: List? = null,
+ val supportsExternalPlayer: Boolean? = null,
+ val limited: Boolean? = null
+)
+
+/**
+ * Installed scraper info with runtime state
+ */
+data class ScraperInfo(
+ val id: String,
+ val name: String,
+ val description: String,
+ val version: String,
+ val filename: String,
+ val supportedTypes: List,
+ val enabled: Boolean,
+ val manifestEnabled: Boolean,
+ val logo: String?,
+ val contentLanguage: List,
+ val repositoryId: String,
+ val formats: List?
+) {
+ fun supportsType(type: String): Boolean {
+ val normalizedType = when (type.lowercase()) {
+ "series", "other" -> "tv"
+ else -> type.lowercase()
+ }
+ return supportedTypes.map { it.lowercase() }.contains(normalizedType)
+ }
+}
+
+/**
+ * Result from a local scraper execution
+ */
+data class LocalScraperResult(
+ val title: String,
+ val name: String? = null,
+ val url: String,
+ val quality: String? = null,
+ val size: String? = null,
+ val language: String? = null,
+ val provider: String? = null,
+ val type: String? = null,
+ val seeders: Int? = null,
+ val peers: Int? = null,
+ val infoHash: String? = null,
+ val headers: Map? = null
+)
+
+/**
+ * Convert LocalScraperResult to Stream
+ */
+fun LocalScraperResult.toStream(scraper: ScraperInfo): com.nuvio.tv.domain.model.Stream {
+ val displayTitle = buildString {
+ append(title)
+ if (!quality.isNullOrBlank() && !title.contains(quality)) {
+ append(" $quality")
+ }
+ }
+
+ val displayName = buildString {
+ append(name ?: scraper.name)
+ if (!quality.isNullOrBlank() && !(name ?: "").contains(quality)) {
+ append(" - $quality")
+ }
+ }
+
+ return Stream(
+ name = displayName,
+ title = displayTitle,
+ description = size,
+ url = url,
+ ytId = null,
+ infoHash = infoHash,
+ fileIdx = null,
+ externalUrl = null,
+ behaviorHints = StreamBehaviorHints(
+ notWebReady = null,
+ bingeGroup = "local-plugin-${scraper.id}",
+ countryWhitelist = null,
+ proxyHeaders = headers?.let { ProxyHeaders(request = it, response = null) }
+ ),
+ addonName = scraper.name,
+ addonLogo = scraper.logo
+ )
+}
diff --git a/app/src/main/java/com/nuvio/tv/domain/model/PosterShape.kt b/app/src/main/java/com/nuvio/tv/domain/model/PosterShape.kt
new file mode 100644
index 00000000..6266eae9
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/model/PosterShape.kt
@@ -0,0 +1,21 @@
+package com.nuvio.tv.domain.model
+
+enum class PosterShape {
+ POSTER,
+ LANDSCAPE,
+ SQUARE;
+
+ companion object {
+ fun fromString(value: String?): PosterShape = when (value?.lowercase()) {
+ "landscape" -> LANDSCAPE
+ "square" -> SQUARE
+ else -> POSTER
+ }
+ }
+
+ fun aspectRatio(): Float = when (this) {
+ POSTER -> 0.675f
+ LANDSCAPE -> 1.78f
+ SQUARE -> 1f
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/domain/model/SavedLibraryItem.kt b/app/src/main/java/com/nuvio/tv/domain/model/SavedLibraryItem.kt
new file mode 100644
index 00000000..3b3f290b
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/model/SavedLibraryItem.kt
@@ -0,0 +1,30 @@
+package com.nuvio.tv.domain.model
+
+data class SavedLibraryItem(
+ val id: String,
+ val type: String,
+ val name: String,
+ val poster: String?,
+ val posterShape: PosterShape,
+ val background: String?,
+ val description: String?,
+ val releaseInfo: String?,
+ val imdbRating: Float?,
+ val genres: List,
+ val addonBaseUrl: String?
+) {
+ fun toMetaPreview(): MetaPreview {
+ return MetaPreview(
+ id = id,
+ type = ContentType.fromString(type),
+ name = name,
+ poster = poster,
+ posterShape = posterShape,
+ background = background,
+ description = description,
+ releaseInfo = releaseInfo,
+ imdbRating = imdbRating,
+ genres = genres
+ )
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/domain/model/Stream.kt b/app/src/main/java/com/nuvio/tv/domain/model/Stream.kt
new file mode 100644
index 00000000..b7405aac
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/model/Stream.kt
@@ -0,0 +1,69 @@
+package com.nuvio.tv.domain.model
+
+/**
+ * Represents a stream source from a Stremio addon
+ */
+data class Stream(
+ val name: String?,
+ val title: String?,
+ val description: String?,
+ val url: String?,
+ val ytId: String?,
+ val infoHash: String?,
+ val fileIdx: Int?,
+ val externalUrl: String?,
+ val behaviorHints: StreamBehaviorHints?,
+ val addonName: String,
+ val addonLogo: String?
+) {
+ /**
+ * Returns the primary stream source URL
+ */
+ fun getStreamUrl(): String? = url ?: externalUrl
+
+ /**
+ * Returns true if this is a torrent stream
+ */
+ fun isTorrent(): Boolean = infoHash != null
+
+ /**
+ * Returns true if this is a YouTube stream
+ */
+ fun isYouTube(): Boolean = ytId != null
+
+ /**
+ * Returns true if this is an external URL (opens in browser)
+ */
+ fun isExternal(): Boolean = externalUrl != null && url == null
+
+ /**
+ * Returns a display name for the stream
+ */
+ fun getDisplayName(): String = name ?: title ?: description ?: "Unknown Stream"
+
+ /**
+ * Returns a display description for the stream
+ */
+ fun getDisplayDescription(): String? = description ?: title
+}
+
+data class StreamBehaviorHints(
+ val notWebReady: Boolean?,
+ val bingeGroup: String?,
+ val countryWhitelist: List?,
+ val proxyHeaders: ProxyHeaders?
+)
+
+data class ProxyHeaders(
+ val request: Map?,
+ val response: Map?
+)
+
+/**
+ * Represents streams grouped by addon source
+ */
+data class AddonStreams(
+ val addonName: String,
+ val addonLogo: String?,
+ val streams: List
+)
diff --git a/app/src/main/java/com/nuvio/tv/domain/model/TmdbSettings.kt b/app/src/main/java/com/nuvio/tv/domain/model/TmdbSettings.kt
new file mode 100644
index 00000000..5942e9c5
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/model/TmdbSettings.kt
@@ -0,0 +1,19 @@
+package com.nuvio.tv.domain.model
+
+data class TmdbSettings(
+ val enabled: Boolean = true,
+ // Group: Artwork (logo, backdrop)
+ val useArtwork: Boolean = true,
+ // Group: Basic Info (description, genres, rating)
+ val useBasicInfo: Boolean = true,
+ // Group: Details (runtime, release info, country, language)
+ val useDetails: Boolean = true,
+ // Group: Credits (cast with photos, director, writer)
+ val useCredits: Boolean = true,
+ // Group: Production companies
+ val useProductions: Boolean = true,
+ // Group: Networks (logo)
+ val useNetworks: Boolean = true,
+ // Group: Episodes (episode titles, overviews, thumbnails)
+ val useEpisodes: Boolean = true
+)
diff --git a/app/src/main/java/com/nuvio/tv/domain/model/WatchProgress.kt b/app/src/main/java/com/nuvio/tv/domain/model/WatchProgress.kt
new file mode 100644
index 00000000..56daa868
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/model/WatchProgress.kt
@@ -0,0 +1,62 @@
+package com.nuvio.tv.domain.model
+
+/**
+ * Represents the watch progress for a content item (movie or episode).
+ */
+data class WatchProgress(
+ val contentId: String, // IMDB ID of the movie/series
+ val contentType: String, // "movie" or "series"
+ val name: String, // Movie or series name
+ val poster: String?, // Poster URL
+ val backdrop: String?, // Backdrop URL
+ val logo: String?, // Logo URL
+ val videoId: String, // Specific video/episode ID being watched
+ val season: Int?, // Season number (null for movies)
+ val episode: Int?, // Episode number (null for movies)
+ val episodeTitle: String?, // Episode title (null for movies)
+ val position: Long, // Current playback position in ms
+ val duration: Long, // Total duration in ms
+ val lastWatched: Long, // Timestamp when last watched
+ val addonBaseUrl: String? = null // Addon that was used to play
+) {
+ /**
+ * Progress percentage (0.0 to 1.0)
+ */
+ val progressPercentage: Float
+ get() = if (duration > 0) (position.toFloat() / duration.toFloat()).coerceIn(0f, 1f) else 0f
+
+ /**
+ * Returns true if the content has been watched past the threshold (typically 90%)
+ */
+ fun isCompleted(threshold: Float = 0.90f): Boolean = progressPercentage >= threshold
+
+ /**
+ * Returns true if the content has been started but not completed
+ */
+ fun isInProgress(startThreshold: Float = 0.02f, endThreshold: Float = 0.90f): Boolean =
+ progressPercentage >= startThreshold && progressPercentage < endThreshold
+
+ /**
+ * Returns the remaining time in milliseconds
+ */
+ val remainingTime: Long
+ get() = (duration - position).coerceAtLeast(0)
+
+ /**
+ * Display string for the episode (e.g., "S1E2")
+ */
+ val episodeDisplayString: String?
+ get() = if (season != null && episode != null) "S${season}E${episode}" else null
+}
+
+/**
+ * Represents the next item to watch for a series or a movie to resume.
+ */
+data class NextToWatch(
+ val watchProgress: WatchProgress?, // Null if nothing has been watched yet
+ val isResume: Boolean, // True if resuming current item, false if next episode
+ val nextVideoId: String?, // Video ID to play next
+ val nextSeason: Int?, // Next season number
+ val nextEpisode: Int?, // Next episode number
+ val displayText: String // Text to show on button (e.g., "Resume S1E2", "Play S1E3")
+)
diff --git a/app/src/main/java/com/nuvio/tv/domain/repository/AddonRepository.kt b/app/src/main/java/com/nuvio/tv/domain/repository/AddonRepository.kt
new file mode 100644
index 00000000..9cbcbbcf
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/repository/AddonRepository.kt
@@ -0,0 +1,12 @@
+package com.nuvio.tv.domain.repository
+
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.domain.model.Addon
+import kotlinx.coroutines.flow.Flow
+
+interface AddonRepository {
+ fun getInstalledAddons(): Flow>
+ suspend fun fetchAddon(baseUrl: String): NetworkResult
+ suspend fun addAddon(url: String)
+ suspend fun removeAddon(url: String)
+}
diff --git a/app/src/main/java/com/nuvio/tv/domain/repository/CatalogRepository.kt b/app/src/main/java/com/nuvio/tv/domain/repository/CatalogRepository.kt
new file mode 100644
index 00000000..6b92864b
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/repository/CatalogRepository.kt
@@ -0,0 +1,18 @@
+package com.nuvio.tv.domain.repository
+
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.domain.model.CatalogRow
+import kotlinx.coroutines.flow.Flow
+
+interface CatalogRepository {
+ fun getCatalog(
+ addonBaseUrl: String,
+ addonId: String,
+ addonName: String,
+ catalogId: String,
+ catalogName: String,
+ type: String,
+ skip: Int = 0,
+ extraArgs: Map = emptyMap()
+ ): Flow>
+}
diff --git a/app/src/main/java/com/nuvio/tv/domain/repository/MetaRepository.kt b/app/src/main/java/com/nuvio/tv/domain/repository/MetaRepository.kt
new file mode 100644
index 00000000..74667339
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/repository/MetaRepository.kt
@@ -0,0 +1,18 @@
+package com.nuvio.tv.domain.repository
+
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.domain.model.Meta
+import kotlinx.coroutines.flow.Flow
+
+interface MetaRepository {
+ fun getMeta(
+ addonBaseUrl: String,
+ type: String,
+ id: String
+ ): Flow>
+
+ fun getMetaFromAllAddons(
+ type: String,
+ id: String
+ ): Flow>
+}
diff --git a/app/src/main/java/com/nuvio/tv/domain/repository/StreamRepository.kt b/app/src/main/java/com/nuvio/tv/domain/repository/StreamRepository.kt
new file mode 100644
index 00000000..ff0c9753
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/repository/StreamRepository.kt
@@ -0,0 +1,36 @@
+package com.nuvio.tv.domain.repository
+
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.domain.model.AddonStreams
+import com.nuvio.tv.domain.model.Stream
+import kotlinx.coroutines.flow.Flow
+
+interface StreamRepository {
+ /**
+ * Fetches streams from all installed addons for a given video ID
+ * @param type The content type (movie, series, etc.)
+ * @param videoId The video ID (for movies: IMDB ID, for series: IMDB_ID:season:episode)
+ * @param season Optional season number for TV shows (used by local plugins)
+ * @param episode Optional episode number for TV shows (used by local plugins)
+ * @return Flow of AddonStreams grouped by addon
+ */
+ fun getStreamsFromAllAddons(
+ type: String,
+ videoId: String,
+ season: Int? = null,
+ episode: Int? = null
+ ): Flow>>
+
+ /**
+ * Fetches streams from a specific addon
+ * @param baseUrl The addon base URL
+ * @param type The content type
+ * @param videoId The video ID
+ * @return NetworkResult containing list of streams
+ */
+ suspend fun getStreamsFromAddon(
+ baseUrl: String,
+ type: String,
+ videoId: String
+ ): NetworkResult>
+}
diff --git a/app/src/main/java/com/nuvio/tv/domain/repository/WatchProgressRepository.kt b/app/src/main/java/com/nuvio/tv/domain/repository/WatchProgressRepository.kt
new file mode 100644
index 00000000..4c58acfe
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/domain/repository/WatchProgressRepository.kt
@@ -0,0 +1,55 @@
+package com.nuvio.tv.domain.repository
+
+import com.nuvio.tv.domain.model.WatchProgress
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * Repository for managing watch progress data.
+ */
+interface WatchProgressRepository {
+
+ /**
+ * Get all watch progress items sorted by last watched (most recent first)
+ */
+ val allProgress: Flow>
+
+ /**
+ * Get items currently in progress (not completed, suitable for "Continue Watching")
+ */
+ val continueWatching: Flow>
+
+ /**
+ * Get watch progress for a specific content item (movie or series)
+ */
+ fun getProgress(contentId: String): Flow
+
+ /**
+ * Get watch progress for a specific episode
+ */
+ fun getEpisodeProgress(contentId: String, season: Int, episode: Int): Flow
+
+ /**
+ * Get all episode progress for a series as a map of (season, episode) to progress
+ */
+ fun getAllEpisodeProgress(contentId: String): Flow, WatchProgress>>
+
+ /**
+ * Save or update watch progress
+ */
+ suspend fun saveProgress(progress: WatchProgress)
+
+ /**
+ * Remove watch progress
+ */
+ suspend fun removeProgress(contentId: String, season: Int? = null, episode: Int? = null)
+
+ /**
+ * Mark content as completed
+ */
+ suspend fun markAsCompleted(progress: WatchProgress)
+
+ /**
+ * Clear all watch progress
+ */
+ suspend fun clearAll()
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/components/CatalogRowSection.kt b/app/src/main/java/com/nuvio/tv/ui/components/CatalogRowSection.kt
new file mode 100644
index 00000000..7646f7bb
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/components/CatalogRowSection.kt
@@ -0,0 +1,109 @@
+package com.nuvio.tv.ui.components
+
+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.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+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.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.nuvio.tv.domain.model.CatalogRow
+import com.nuvio.tv.ui.theme.NuvioColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun CatalogRowSection(
+ catalogRow: CatalogRow,
+ onItemClick: (String, String, String) -> Unit,
+ onLoadMore: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val listState = rememberLazyListState()
+
+ val shouldLoadMore by remember {
+ derivedStateOf {
+ val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
+ val totalItems = listState.layoutInfo.totalItemsCount
+ lastVisibleItem >= totalItems - 5 && catalogRow.hasMore && !catalogRow.isLoading
+ }
+ }
+
+ LaunchedEffect(shouldLoadMore) {
+ if (shouldLoadMore) {
+ onLoadMore()
+ }
+ }
+
+ Column(modifier = modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 48.dp, end = 48.dp, bottom = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column {
+ Text(
+ text = catalogRow.catalogName,
+ style = MaterialTheme.typography.headlineMedium,
+ color = NuvioColors.TextPrimary
+ )
+ Text(
+ text = "from ${catalogRow.addonName}",
+ style = MaterialTheme.typography.labelMedium,
+ color = NuvioColors.TextTertiary
+ )
+ }
+ }
+
+ LazyRow(
+ state = listState,
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 48.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ itemsIndexed(
+ items = catalogRow.items,
+ key = { _, item -> "${catalogRow.type}_${catalogRow.catalogId}_${item.id}" }
+ ) { index, item ->
+ ContentCard(
+ item = item,
+ onClick = { onItemClick(item.id, item.type.toApiString(), catalogRow.addonBaseUrl) },
+ modifier = Modifier
+ )
+ }
+
+ if (catalogRow.isLoading) {
+
+ item {
+
+ Box(
+ modifier = Modifier
+ .width(150.dp)
+ .height(225.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ LoadingIndicator()
+ }
+ }
+
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt b/app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt
new file mode 100644
index 00000000..25e9c4b8
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt
@@ -0,0 +1,128 @@
+package com.nuvio.tv.ui.components
+
+import androidx.compose.foundation.BorderStroke
+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.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.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Border
+import androidx.tv.material3.Card
+import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Glow
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import com.nuvio.tv.domain.model.MetaPreview
+import com.nuvio.tv.domain.model.PosterShape
+import com.nuvio.tv.ui.theme.NuvioColors
+import com.nuvio.tv.ui.theme.NuvioTheme
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun ContentCard(
+ item: MetaPreview,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit = {}
+) {
+ var isFocused by remember { mutableStateOf(false) }
+
+ val cardWidth = when (item.posterShape) {
+ PosterShape.POSTER -> 140.dp
+ PosterShape.LANDSCAPE -> 260.dp
+ PosterShape.SQUARE -> 170.dp
+ }
+ val cardHeight = when (item.posterShape) {
+ PosterShape.POSTER -> 210.dp
+ PosterShape.LANDSCAPE -> 148.dp
+ PosterShape.SQUARE -> 170.dp
+ }
+
+ Column(
+ modifier = modifier.width(cardWidth)
+ ) {
+ Card(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .onFocusChanged { isFocused = it.isFocused },
+ shape = CardDefaults.shape(
+ shape = RoundedCornerShape(8.dp)
+ ),
+ colors = CardDefaults.colors(
+ containerColor = NuvioColors.BackgroundCard,
+ focusedContainerColor = NuvioColors.BackgroundCard
+ ),
+ border = CardDefaults.border(
+ focusedBorder = Border(
+ border = BorderStroke(3.dp, NuvioColors.FocusRing),
+ shape = RoundedCornerShape(8.dp)
+ )
+ ),
+ scale = CardDefaults.scale(
+ focusedScale = 1.08f
+ ),
+ glow = CardDefaults.glow(
+ focusedGlow = Glow(
+ elevation = 8.dp,
+ elevationColor = NuvioColors.FocusRing.copy(alpha = 0.3f)
+ )
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(cardHeight)
+ .clip(RoundedCornerShape(8.dp))
+ ) {
+ AsyncImage(
+ model = item.poster,
+ contentDescription = item.name,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp)
+ ) {
+ Text(
+ text = item.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = NuvioColors.TextPrimary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ item.releaseInfo?.let { release ->
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = release,
+ style = MaterialTheme.typography.labelMedium,
+ color = NuvioTheme.extendedColors.textSecondary,
+ maxLines = 1
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/components/ContinueWatchingSection.kt b/app/src/main/java/com/nuvio/tv/ui/components/ContinueWatchingSection.kt
new file mode 100644
index 00000000..95a08a4a
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/components/ContinueWatchingSection.kt
@@ -0,0 +1,251 @@
+package com.nuvio.tv.ui.components
+
+import androidx.compose.foundation.BorderStroke
+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.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+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.foundation.lazy.list.TvLazyRow
+import androidx.tv.foundation.lazy.list.items
+import androidx.tv.material3.Border
+import androidx.tv.material3.Card
+import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import com.nuvio.tv.domain.model.WatchProgress
+import com.nuvio.tv.ui.theme.NuvioColors
+import com.nuvio.tv.ui.theme.NuvioTheme
+import java.util.concurrent.TimeUnit
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun ContinueWatchingSection(
+ items: List,
+ onItemClick: (WatchProgress) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ if (items.isEmpty()) return
+
+ Column(modifier = modifier) {
+ Text(
+ text = "Continue Watching",
+ style = MaterialTheme.typography.titleLarge,
+ color = NuvioColors.TextPrimary,
+ modifier = Modifier.padding(start = 48.dp, bottom = 16.dp)
+ )
+
+ TvLazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 48.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ items(
+ items = items,
+ key = { progress ->
+ // Create unique key using videoId which is always unique per episode
+ progress.videoId
+ }
+ ) { progress ->
+ ContinueWatchingCard(
+ progress = progress,
+ onClick = { onItemClick(progress) }
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun ContinueWatchingCard(
+ progress: WatchProgress,
+ onClick: () -> Unit
+) {
+ var isFocused by remember { mutableStateOf(false) }
+
+ Card(
+ onClick = onClick,
+ modifier = Modifier
+ .width(320.dp)
+ .onFocusChanged { isFocused = it.isFocused },
+ shape = CardDefaults.shape(
+ shape = RoundedCornerShape(12.dp)
+ ),
+ colors = CardDefaults.colors(
+ containerColor = NuvioColors.BackgroundCard,
+ focusedContainerColor = NuvioColors.BackgroundCard
+ ),
+ border = CardDefaults.border(
+ focusedBorder = Border(
+ border = BorderStroke(2.dp, NuvioColors.FocusRing),
+ shape = RoundedCornerShape(12.dp)
+ )
+ ),
+ scale = CardDefaults.scale(
+ focusedScale = 1.05f
+ )
+ ) {
+ Column {
+ // Thumbnail with progress overlay
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(180.dp)
+ .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp))
+ ) {
+ // Background image
+ AsyncImage(
+ model = progress.backdrop ?: progress.poster,
+ contentDescription = progress.name,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+
+ // Gradient overlay for text readability
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colorStops = arrayOf(
+ 0.0f to Color.Transparent,
+ 0.5f to Color.Transparent,
+ 0.8f to NuvioColors.Background.copy(alpha = 0.7f),
+ 1.0f to NuvioColors.Background.copy(alpha = 0.95f)
+ )
+ )
+ )
+ )
+
+ // Play icon overlay when focused
+ if (isFocused) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.3f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(50))
+ .background(NuvioColors.Primary)
+ .padding(16.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = "Play",
+ tint = NuvioColors.OnPrimary
+ )
+ }
+ }
+ }
+
+ // Content info at bottom
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(12.dp)
+ ) {
+ // Episode info (for series)
+ progress.episodeDisplayString?.let { episodeStr ->
+ Text(
+ text = episodeStr,
+ style = MaterialTheme.typography.labelMedium,
+ color = NuvioColors.Primary
+ )
+ }
+
+ Text(
+ text = progress.name,
+ style = MaterialTheme.typography.titleSmall,
+ color = NuvioColors.TextPrimary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ // Episode title if available
+ progress.episodeTitle?.let { title ->
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioTheme.extendedColors.textSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ // Remaining time badge
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(8.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(NuvioColors.Background.copy(alpha = 0.8f))
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Text(
+ text = formatRemainingTime(progress.remainingTime),
+ style = MaterialTheme.typography.labelSmall,
+ color = NuvioColors.TextPrimary
+ )
+ }
+ }
+
+ // Progress bar
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(4.dp)
+ .background(NuvioColors.SurfaceVariant)
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(progress.progressPercentage)
+ .height(4.dp)
+ .background(NuvioColors.Primary)
+ )
+ }
+ }
+ }
+}
+
+private fun formatRemainingTime(remainingMs: Long): String {
+ val totalMinutes = TimeUnit.MILLISECONDS.toMinutes(remainingMs)
+ val hours = totalMinutes / 60
+ val minutes = totalMinutes % 60
+
+ return when {
+ hours > 0 -> "${hours}h ${minutes}m left"
+ minutes > 0 -> "${minutes}m left"
+ else -> "Almost done"
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/components/ErrorState.kt b/app/src/main/java/com/nuvio/tv/ui/components/ErrorState.kt
new file mode 100644
index 00000000..e46399b5
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/components/ErrorState.kt
@@ -0,0 +1,47 @@
+package com.nuvio.tv.ui.components
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.Button
+import androidx.tv.material3.ButtonDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.nuvio.tv.ui.theme.NuvioColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun ErrorState(
+ message: String,
+ onRetry: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyLarge,
+ color = NuvioColors.TextSecondary
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Button(
+ onClick = onRetry,
+ colors = ButtonDefaults.colors(
+ containerColor = NuvioColors.Primary,
+ contentColor = NuvioColors.OnPrimary
+ )
+ ) {
+ Text("Retry")
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/components/LoadingIndicator.kt b/app/src/main/java/com/nuvio/tv/ui/components/LoadingIndicator.kt
new file mode 100644
index 00000000..5287f4c9
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/components/LoadingIndicator.kt
@@ -0,0 +1,25 @@
+package com.nuvio.tv.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.nuvio.tv.ui.theme.NuvioColors
+
+@Composable
+fun LoadingIndicator(
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier,
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ color = NuvioColors.Primary
+ )
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/components/NuvioTopBar.kt b/app/src/main/java/com/nuvio/tv/ui/components/NuvioTopBar.kt
new file mode 100644
index 00000000..a99ccfb1
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/components/NuvioTopBar.kt
@@ -0,0 +1,67 @@
+package com.nuvio.tv.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.nuvio.tv.ui.theme.NuvioColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun NuvioTopBar(
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .height(80.dp)
+ .background(NuvioColors.Background)
+ .padding(horizontal = 48.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "NUVIO",
+ style = MaterialTheme.typography.headlineLarge.copy(
+ fontWeight = FontWeight.Bold
+ ),
+ color = NuvioColors.Primary
+ )
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(32.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ TopBarNavItem(text = "Home", isSelected = true)
+ TopBarNavItem(text = "Movies", isSelected = false)
+ TopBarNavItem(text = "Series", isSelected = false)
+ TopBarNavItem(text = "Search", isSelected = false)
+ TopBarNavItem(text = "Settings", isSelected = false)
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun TopBarNavItem(
+ text: String,
+ isSelected: Boolean,
+ modifier: Modifier = Modifier
+) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleMedium,
+ color = if (isSelected) NuvioColors.Primary else NuvioColors.TextSecondary,
+ modifier = modifier
+ )
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/components/SidebarNavigation.kt b/app/src/main/java/com/nuvio/tv/ui/components/SidebarNavigation.kt
new file mode 100644
index 00000000..87846506
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/components/SidebarNavigation.kt
@@ -0,0 +1,155 @@
+package com.nuvio.tv.ui.components
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.animateIntOffsetAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.nuvio.tv.ui.theme.NuvioColors
+
+data class SidebarItem(
+ val route: String,
+ val label: String,
+ val icon: ImageVector
+)
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun SidebarNavigation(
+ items: List,
+ selectedRoute: String?,
+ isExpanded: Boolean,
+ onExpandedChange: (Boolean) -> Unit,
+ focusRequester: FocusRequester,
+ onFocusChange: (Boolean) -> Unit,
+ onNavigate: (String) -> Unit
+) {
+ val sidebarWidthPx = with(LocalDensity.current) { 260.dp.roundToPx() }
+ val offsetX by animateIntOffsetAsState(
+ targetValue = if (isExpanded) IntOffset.Zero else IntOffset(-sidebarWidthPx, 0),
+ label = "sidebarOffset"
+ )
+
+ Column(
+ modifier = Modifier
+ .offset { offsetX }
+ .width(260.dp)
+ .fillMaxHeight()
+ .background(NuvioColors.BackgroundElevated)
+ .padding(vertical = 24.dp, horizontal = 16.dp)
+ .onFocusChanged { state ->
+ onFocusChange(state.hasFocus)
+ onExpandedChange(state.hasFocus)
+ },
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = "NUVIO",
+ style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
+ color = NuvioColors.Primary
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ items.forEach { item ->
+ SidebarNavItem(
+ item = item,
+ isSelected = item.route == selectedRoute,
+ focusRequester = if (item.route == selectedRoute) focusRequester else null,
+ onNavigate = onNavigate
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun SidebarNavItem(
+ item: SidebarItem,
+ isSelected: Boolean,
+ focusRequester: FocusRequester?,
+ onNavigate: (String) -> Unit
+) {
+ var isFocused by remember { mutableStateOf(false) }
+ val shape = RoundedCornerShape(14.dp)
+ val backgroundColor by animateColorAsState(
+ targetValue = if (isFocused || isSelected) NuvioColors.FocusBackground else Color.Transparent,
+ label = "navItemBackground"
+ )
+ val borderColor by animateColorAsState(
+ targetValue = if (isFocused) NuvioColors.BorderFocused else Color.Transparent,
+ label = "navItemBorder"
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .clip(shape)
+ .background(backgroundColor)
+ .border(width = 1.dp, color = borderColor, shape = shape)
+ .then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
+ .onFocusChanged { state ->
+ isFocused = state.isFocused
+ }
+ .clickable { onNavigate(item.route) }
+ .padding(horizontal = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(RoundedCornerShape(10.dp))
+ .background(NuvioColors.SurfaceVariant),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = item.icon,
+ contentDescription = item.label,
+ tint = NuvioColors.TextPrimary,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+
+ Text(
+ text = item.label,
+ style = MaterialTheme.typography.titleMedium,
+ color = if (isFocused || isSelected) NuvioColors.TextPrimary else NuvioColors.TextSecondary
+ )
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt
new file mode 100644
index 00000000..62d5517f
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt
@@ -0,0 +1,263 @@
+package com.nuvio.tv.ui.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import com.nuvio.tv.ui.screens.detail.MetaDetailsScreen
+import com.nuvio.tv.ui.screens.home.HomeScreen
+import com.nuvio.tv.ui.screens.addon.AddonManagerScreen
+import com.nuvio.tv.ui.screens.library.LibraryScreen
+import com.nuvio.tv.ui.screens.player.PlayerScreen
+import com.nuvio.tv.ui.screens.plugin.PluginScreen
+import com.nuvio.tv.ui.screens.search.SearchScreen
+import com.nuvio.tv.ui.screens.settings.SettingsScreen
+import com.nuvio.tv.ui.screens.settings.TmdbSettingsScreen
+import com.nuvio.tv.ui.screens.stream.StreamScreen
+
+@Composable
+fun NuvioNavHost(
+ navController: NavHostController,
+ startDestination: String = Screen.Home.route
+) {
+ NavHost(
+ navController = navController,
+ startDestination = startDestination
+ ) {
+ composable(Screen.Home.route) {
+ HomeScreen(
+ onNavigateToDetail = { itemId, itemType, addonBaseUrl ->
+ navController.navigate(Screen.Detail.createRoute(itemId, itemType, addonBaseUrl))
+ }
+ )
+ }
+
+ composable(
+ route = Screen.Detail.route,
+ arguments = listOf(
+ navArgument("itemId") { type = NavType.StringType },
+ navArgument("itemType") { type = NavType.StringType },
+ navArgument("addonBaseUrl") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ }
+ )
+ ) { backStackEntry ->
+ MetaDetailsScreen(
+ onBackPress = { navController.popBackStack() },
+ onPlayClick = { videoId, contentType, contentId, title, poster, backdrop, logo, season, episode, episodeName, genres, year ->
+ navController.navigate(
+ Screen.Stream.createRoute(
+ videoId = videoId,
+ contentType = contentType,
+ title = title,
+ poster = poster,
+ backdrop = backdrop,
+ logo = logo,
+ season = season,
+ episode = episode,
+ episodeName = episodeName,
+ genres = genres,
+ year = year,
+ contentId = contentId,
+ contentName = title
+ )
+ )
+ }
+ )
+ }
+
+ composable(
+ route = Screen.Stream.route,
+ arguments = listOf(
+ navArgument("videoId") { type = NavType.StringType },
+ navArgument("contentType") { type = NavType.StringType },
+ navArgument("title") { type = NavType.StringType },
+ navArgument("poster") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("backdrop") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("logo") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("season") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("episode") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("episodeName") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("genres") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("year") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("contentId") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("contentName") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ }
+ )
+ ) {
+ StreamScreen(
+ onBackPress = { navController.popBackStack() },
+ onStreamSelected = { playbackInfo ->
+ playbackInfo.url?.let { url ->
+ navController.navigate(
+ Screen.Player.createRoute(
+ streamUrl = url,
+ title = playbackInfo.title,
+ headers = playbackInfo.headers,
+ contentId = playbackInfo.contentId,
+ contentType = playbackInfo.contentType,
+ contentName = playbackInfo.contentName,
+ poster = playbackInfo.poster,
+ backdrop = playbackInfo.backdrop,
+ logo = playbackInfo.logo,
+ videoId = playbackInfo.videoId,
+ season = playbackInfo.season,
+ episode = playbackInfo.episode,
+ episodeTitle = playbackInfo.episodeTitle
+ )
+ )
+ }
+ }
+ )
+ }
+
+ composable(
+ route = Screen.Player.route,
+ arguments = listOf(
+ navArgument("streamUrl") { type = NavType.StringType },
+ navArgument("title") { type = NavType.StringType },
+ navArgument("headers") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("contentId") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("contentType") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("contentName") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("poster") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("backdrop") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("logo") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("videoId") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("season") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("episode") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ },
+ navArgument("episodeTitle") {
+ type = NavType.StringType
+ nullable = true
+ defaultValue = null
+ }
+ )
+ ) {
+ PlayerScreen(
+ onBackPress = { navController.popBackStack() }
+ )
+ }
+
+ composable(Screen.Search.route) {
+ SearchScreen(
+ onNavigateToDetail = { itemId, itemType, addonBaseUrl ->
+ navController.navigate(Screen.Detail.createRoute(itemId, itemType, addonBaseUrl))
+ }
+ )
+ }
+
+ composable(Screen.Library.route) {
+ LibraryScreen(
+ onNavigateToDetail = { itemId, itemType, addonBaseUrl ->
+ navController.navigate(Screen.Detail.createRoute(itemId, itemType, addonBaseUrl))
+ }
+ )
+ }
+
+ composable(Screen.Settings.route) {
+ SettingsScreen(
+ onNavigateToPlugins = { navController.navigate(Screen.Plugins.route) },
+ onNavigateToTmdb = { navController.navigate(Screen.TmdbSettings.route) }
+ )
+ }
+
+ composable(Screen.TmdbSettings.route) {
+ TmdbSettingsScreen(
+ onBackPress = { navController.popBackStack() }
+ )
+ }
+
+ composable(Screen.AddonManager.route) {
+ AddonManagerScreen()
+ }
+
+ composable(Screen.Plugins.route) {
+ PluginScreen(
+ onBackPress = { navController.popBackStack() }
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt
new file mode 100644
index 00000000..56b91299
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt
@@ -0,0 +1,88 @@
+package com.nuvio.tv.ui.navigation
+
+import java.net.URLEncoder
+
+sealed class Screen(val route: String) {
+ data object Home : Screen("home")
+ data object Detail : Screen("detail/{itemId}/{itemType}?addonBaseUrl={addonBaseUrl}") {
+ private fun encode(value: String): String =
+ URLEncoder.encode(value, "UTF-8").replace("+", "%20")
+
+ fun createRoute(itemId: String, itemType: String, addonBaseUrl: String? = null): String {
+ val encodedAddon = addonBaseUrl?.let { encode(it) } ?: ""
+ return "detail/$itemId/$itemType?addonBaseUrl=$encodedAddon"
+ }
+ }
+ data object Stream : Screen("stream/{videoId}/{contentType}/{title}?poster={poster}&backdrop={backdrop}&logo={logo}&season={season}&episode={episode}&episodeName={episodeName}&genres={genres}&year={year}&contentId={contentId}&contentName={contentName}") {
+ private fun encode(value: String): String =
+ URLEncoder.encode(value, "UTF-8").replace("+", "%20")
+
+ fun createRoute(
+ videoId: String,
+ contentType: String,
+ title: String,
+ poster: String? = null,
+ backdrop: String? = null,
+ logo: String? = null,
+ season: Int? = null,
+ episode: Int? = null,
+ episodeName: String? = null,
+ genres: String? = null,
+ year: String? = null,
+ contentId: String? = null,
+ contentName: String? = null
+ ): String {
+ val encodedTitle = encode(title)
+ val encodedPoster = poster?.let { encode(it) } ?: ""
+ val encodedBackdrop = backdrop?.let { encode(it) } ?: ""
+ val encodedLogo = logo?.let { encode(it) } ?: ""
+ val encodedEpisodeName = episodeName?.let { encode(it) } ?: ""
+ val encodedGenres = genres?.let { encode(it) } ?: ""
+ val encodedYear = year?.let { encode(it) } ?: ""
+ val encodedContentId = contentId?.let { encode(it) } ?: ""
+ val encodedContentName = contentName?.let { encode(it) } ?: ""
+ return "stream/$videoId/$contentType/$encodedTitle?poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&season=${season ?: ""}&episode=${episode ?: ""}&episodeName=$encodedEpisodeName&genres=$encodedGenres&year=$encodedYear&contentId=$encodedContentId&contentName=$encodedContentName"
+ }
+ }
+ data object Player : Screen("player/{streamUrl}/{title}?headers={headers}&contentId={contentId}&contentType={contentType}&contentName={contentName}&poster={poster}&backdrop={backdrop}&logo={logo}&videoId={videoId}&season={season}&episode={episode}&episodeTitle={episodeTitle}") {
+ private fun encode(value: String): String =
+ URLEncoder.encode(value, "UTF-8").replace("+", "%20")
+
+ fun createRoute(
+ streamUrl: String,
+ title: String,
+ headers: Map? = null,
+ contentId: String? = null,
+ contentType: String? = null,
+ contentName: String? = null,
+ poster: String? = null,
+ backdrop: String? = null,
+ logo: String? = null,
+ videoId: String? = null,
+ season: Int? = null,
+ episode: Int? = null,
+ episodeTitle: String? = null
+ ): String {
+ val encodedUrl = encode(streamUrl)
+ val encodedTitle = encode(title)
+ val encodedHeaders = headers?.entries?.joinToString("&") { (k, v) ->
+ "${encode(k)}=${encode(v)}"
+ }?.let { encode(it) } ?: ""
+ val encodedContentId = contentId?.let { encode(it) } ?: ""
+ val encodedContentType = contentType?.let { encode(it) } ?: ""
+ val encodedContentName = contentName?.let { encode(it) } ?: ""
+ val encodedPoster = poster?.let { encode(it) } ?: ""
+ val encodedBackdrop = backdrop?.let { encode(it) } ?: ""
+ val encodedLogo = logo?.let { encode(it) } ?: ""
+ val encodedVideoId = videoId?.let { encode(it) } ?: ""
+ val encodedEpisodeTitle = episodeTitle?.let { encode(it) } ?: ""
+ return "player/$encodedUrl/$encodedTitle?headers=$encodedHeaders&contentId=$encodedContentId&contentType=$encodedContentType&contentName=$encodedContentName&poster=$encodedPoster&backdrop=$encodedBackdrop&logo=$encodedLogo&videoId=$encodedVideoId&season=${season ?: ""}&episode=${episode ?: ""}&episodeTitle=$encodedEpisodeTitle"
+ }
+ }
+ data object Search : Screen("search")
+ data object Library : Screen("library")
+ data object Settings : Screen("settings")
+ data object TmdbSettings : Screen("tmdb_settings")
+ data object AddonManager : Screen("addon_manager")
+ data object Plugins : Screen("plugins")
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/addon/AddonManagerScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/addon/AddonManagerScreen.kt
new file mode 100644
index 00000000..952b623c
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/addon/AddonManagerScreen.kt
@@ -0,0 +1,222 @@
+package com.nuvio.tv.ui.screens.addon
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+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.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.TextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.tv.foundation.lazy.list.TvLazyColumn
+import androidx.tv.foundation.lazy.list.items
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.nuvio.tv.domain.model.Addon
+import com.nuvio.tv.ui.components.LoadingIndicator
+import com.nuvio.tv.ui.theme.NuvioColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun AddonManagerScreen(
+ viewModel: AddonManagerViewModel = hiltViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(NuvioColors.Background)
+ ) {
+ TvLazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(horizontal = 36.dp, vertical = 28.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
+ item {
+ Text(
+ text = "Addons",
+ style = MaterialTheme.typography.headlineMedium,
+ color = NuvioColors.TextPrimary
+ )
+ }
+
+ item {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .animateContentSize(),
+ colors = CardDefaults.cardColors(containerColor = NuvioColors.BackgroundCard),
+ shape = RoundedCornerShape(18.dp)
+ ) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Text(
+ text = "Install addon",
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ color = NuvioColors.TextPrimary
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ TextField(
+ value = uiState.installUrl,
+ onValueChange = viewModel::onInstallUrlChange,
+ placeholder = { Text(text = "https://example.com") },
+ modifier = Modifier.weight(1f)
+ )
+ Button(
+ onClick = viewModel::installAddon,
+ enabled = !uiState.isInstalling,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = NuvioColors.Primary,
+ contentColor = NuvioColors.OnPrimary
+ )
+ ) {
+ Text(text = if (uiState.isInstalling) "Installing" else "Install")
+ }
+ }
+
+ AnimatedVisibility(visible = uiState.error != null) {
+ Text(
+ text = uiState.error.orEmpty(),
+ style = MaterialTheme.typography.bodyMedium,
+ color = NuvioColors.Error,
+ modifier = Modifier.padding(top = 10.dp)
+ )
+ }
+ }
+ }
+ }
+
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Installed",
+ style = MaterialTheme.typography.titleLarge,
+ color = NuvioColors.TextPrimary
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ if (uiState.isLoading) {
+ LoadingIndicator(modifier = Modifier.height(24.dp))
+ }
+ }
+ }
+
+ if (uiState.installedAddons.isEmpty() && !uiState.isLoading) {
+ item {
+ Text(
+ text = "No addons installed. Add one to get started.",
+ style = MaterialTheme.typography.bodyLarge,
+ color = NuvioColors.TextSecondary
+ )
+ }
+ } else {
+ items(
+ items = uiState.installedAddons,
+ key = { addon -> addon.id }
+ ) { addon ->
+ AddonCard(
+ addon = addon,
+ onRemove = { viewModel.removeAddon(addon.baseUrl) }
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun AddonCard(
+ addon: Addon,
+ onRemove: () -> Unit
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .animateContentSize(),
+ colors = CardDefaults.cardColors(containerColor = NuvioColors.BackgroundCard),
+ shape = RoundedCornerShape(18.dp)
+ ) {
+ Column(modifier = Modifier.padding(20.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = addon.name,
+ style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ color = NuvioColors.TextPrimary
+ )
+ Text(
+ text = "v${addon.version}",
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioColors.TextSecondary
+ )
+ }
+ Button(
+ onClick = onRemove,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color.Transparent,
+ contentColor = NuvioColors.TextSecondary
+ )
+ ) {
+ Text(text = "Remove")
+ }
+ }
+
+ if (!addon.description.isNullOrBlank()) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = addon.description ?: "",
+ style = MaterialTheme.typography.bodyMedium,
+ color = NuvioColors.TextSecondary
+ )
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = addon.baseUrl,
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioColors.TextTertiary
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = "Catalogs: ${addon.catalogs.size} • Types: ${addon.types.joinToString { it.toApiString() }}",
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioColors.TextTertiary
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/addon/AddonManagerUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/addon/AddonManagerUiState.kt
new file mode 100644
index 00000000..89efbc69
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/addon/AddonManagerUiState.kt
@@ -0,0 +1,11 @@
+package com.nuvio.tv.ui.screens.addon
+
+import com.nuvio.tv.domain.model.Addon
+
+data class AddonManagerUiState(
+ val isLoading: Boolean = false,
+ val isInstalling: Boolean = false,
+ val installUrl: String = "",
+ val installedAddons: List = emptyList(),
+ val error: String? = null
+)
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/addon/AddonManagerViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/addon/AddonManagerViewModel.kt
new file mode 100644
index 00000000..08b18b4c
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/addon/AddonManagerViewModel.kt
@@ -0,0 +1,107 @@
+package com.nuvio.tv.ui.screens.addon
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.domain.repository.AddonRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class AddonManagerViewModel @Inject constructor(
+ private val addonRepository: AddonRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(AddonManagerUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ observeInstalledAddons()
+ }
+
+ fun onInstallUrlChange(url: String) {
+ _uiState.update { it.copy(installUrl = url, error = null) }
+ }
+
+ fun installAddon() {
+ val rawUrl = uiState.value.installUrl.trim()
+ if (rawUrl.isBlank()) {
+ _uiState.update { it.copy(error = "Enter a valid addon URL") }
+ return
+ }
+
+ val normalizedUrl = normalizeAddonUrl(rawUrl)
+ if (normalizedUrl == null) {
+ _uiState.update { it.copy(error = "Addon URL must start with http or https") }
+ return
+ }
+
+ viewModelScope.launch {
+ _uiState.update { it.copy(isInstalling = true, error = null) }
+
+ when (val result = addonRepository.fetchAddon(normalizedUrl)) {
+ is NetworkResult.Success -> {
+ addonRepository.addAddon(normalizedUrl)
+ _uiState.update { it.copy(isInstalling = false, installUrl = "") }
+ }
+ is NetworkResult.Error -> {
+ _uiState.update {
+ it.copy(
+ isInstalling = false,
+ error = result.message ?: "Unable to install addon"
+ )
+ }
+ }
+ NetworkResult.Loading -> {
+ _uiState.update { it.copy(isInstalling = true) }
+ }
+ }
+ }
+ }
+
+ private fun normalizeAddonUrl(input: String): String? {
+ val trimmed = input.trim()
+ if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) {
+ return null
+ }
+
+ val withoutManifest = if (trimmed.endsWith("/manifest.json")) {
+ trimmed.removeSuffix("/manifest.json")
+ } else {
+ trimmed
+ }
+
+ return withoutManifest.trimEnd('/')
+ }
+
+ fun removeAddon(baseUrl: String) {
+ viewModelScope.launch {
+ addonRepository.removeAddon(baseUrl)
+ }
+ }
+
+ private fun observeInstalledAddons() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true) }
+ addonRepository.getInstalledAddons()
+ .catch { error ->
+ _uiState.update { it.copy(isLoading = false, error = error.message) }
+ }
+ .collect { addons ->
+ _uiState.update { state ->
+ state.copy(
+ installedAddons = addons,
+ isLoading = false,
+ error = null
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/CastSection.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/CastSection.kt
new file mode 100644
index 00000000..8e560633
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/CastSection.kt
@@ -0,0 +1,146 @@
+package com.nuvio.tv.ui.screens.detail
+
+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.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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.foundation.shape.CircleShape
+import androidx.tv.foundation.lazy.list.TvLazyRow
+import androidx.tv.foundation.lazy.list.items
+import androidx.tv.material3.Border
+import androidx.tv.material3.Card
+import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import com.nuvio.tv.domain.model.MetaCastMember
+import com.nuvio.tv.ui.theme.NuvioColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun CastSection(
+ cast: List,
+ modifier: Modifier = Modifier
+) {
+ if (cast.isEmpty()) return
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(top = 20.dp, bottom = 8.dp)
+ ) {
+ Text(
+ text = "Cast",
+ style = MaterialTheme.typography.titleLarge,
+ color = NuvioColors.TextPrimary,
+ modifier = Modifier.padding(horizontal = 48.dp)
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ TvLazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 48.dp, vertical = 6.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ items(
+ items = cast,
+ key = { member ->
+ member.name + "|" + (member.character ?: "") + "|" + (member.photo ?: "")
+ }
+ ) { member ->
+ CastMemberItem(member = member)
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun CastMemberItem(
+ member: MetaCastMember
+) {
+ Column(
+ modifier = Modifier.width(120.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Card(
+ onClick = { /* no-op (for focus + row scrolling) */ },
+ modifier = Modifier
+ .size(72.dp),
+ shape = CardDefaults.shape(
+ shape = CircleShape
+ ),
+ colors = CardDefaults.colors(
+ containerColor = NuvioColors.SurfaceVariant,
+ focusedContainerColor = NuvioColors.FocusBackground
+ ),
+ border = CardDefaults.border(
+ focusedBorder = Border(
+ border = androidx.compose.foundation.BorderStroke(2.dp, NuvioColors.FocusRing),
+ shape = CircleShape
+ )
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .clip(CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ val photo = member.photo
+ if (!photo.isNullOrBlank()) {
+ AsyncImage(
+ model = photo,
+ contentDescription = member.name,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ Text(
+ text = member.name.firstOrNull()?.uppercase() ?: "?",
+ style = MaterialTheme.typography.titleLarge,
+ color = NuvioColors.TextPrimary
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(10.dp))
+
+ Text(
+ text = member.name,
+ style = MaterialTheme.typography.labelMedium,
+ color = NuvioColors.TextSecondary,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ val character = member.character
+ if (!character.isNullOrBlank()) {
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = character,
+ style = MaterialTheme.typography.labelSmall,
+ color = NuvioColors.TextTertiary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/CompanyLogosSection.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/CompanyLogosSection.kt
new file mode 100644
index 00000000..cd917232
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/CompanyLogosSection.kt
@@ -0,0 +1,93 @@
+@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class)
+
+package com.nuvio.tv.ui.screens.detail
+
+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.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.draw.clip
+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.foundation.lazy.list.TvLazyRow
+import androidx.tv.foundation.lazy.list.items
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import com.nuvio.tv.domain.model.MetaCompany
+import com.nuvio.tv.ui.theme.NuvioColors
+import com.nuvio.tv.ui.theme.NuvioTheme
+
+@Composable
+fun CompanyLogosSection(
+ title: String,
+ companies: List
+) {
+ if (companies.isEmpty()) return
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 20.dp, bottom = 8.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ color = NuvioColors.TextPrimary,
+ modifier = Modifier.padding(horizontal = 48.dp)
+ )
+
+ TvLazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 48.dp, vertical = 6.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(companies, key = { it.name }) { company ->
+ CompanyLogoCard(company = company)
+ }
+ }
+ }
+}
+
+@Composable
+private fun CompanyLogoCard(company: MetaCompany) {
+ Box(
+ modifier = Modifier
+ .width(140.dp)
+ .height(56.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(Color.White),
+ contentAlignment = Alignment.Center
+ ) {
+ if (company.logo != null) {
+ AsyncImage(
+ model = company.logo,
+ contentDescription = company.name,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ contentScale = ContentScale.Fit
+ )
+ } else {
+ Text(
+ text = company.name,
+ style = MaterialTheme.typography.labelLarge,
+ color = NuvioTheme.extendedColors.textSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/DateFormat.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/DateFormat.kt
new file mode 100644
index 00000000..f7901882
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/DateFormat.kt
@@ -0,0 +1,25 @@
+package com.nuvio.tv.ui.screens.detail
+
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+
+fun formatReleaseDate(isoDate: String): String {
+ return try {
+ val inputFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+ val outputFormat = SimpleDateFormat("MMMM d, yyyy", Locale.US)
+ val date = inputFormat.parse(isoDate)
+ date?.let { outputFormat.format(it) } ?: ""
+ } catch (e: Exception) {
+ try {
+ val inputFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+ val outputFormat = SimpleDateFormat("MMMM d, yyyy", Locale.US)
+ val date = inputFormat.parse(isoDate)
+ date?.let { outputFormat.format(it) } ?: ""
+ } catch (e: Exception) {
+ ""
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/EpisodesSection.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/EpisodesSection.kt
new file mode 100644
index 00000000..f69b2ed5
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/EpisodesSection.kt
@@ -0,0 +1,286 @@
+package com.nuvio.tv.ui.screens.detail
+
+import androidx.compose.foundation.BorderStroke
+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.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.tv.foundation.lazy.list.TvLazyRow
+import androidx.tv.foundation.lazy.list.items
+import androidx.tv.material3.Border
+import androidx.tv.material3.Card
+import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import com.nuvio.tv.domain.model.Video
+import com.nuvio.tv.ui.theme.NuvioColors
+import com.nuvio.tv.ui.theme.NuvioTheme
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun SeasonTabs(
+ seasons: List,
+ selectedSeason: Int,
+ onSeasonSelected: (Int) -> Unit,
+ selectedTabFocusRequester: FocusRequester
+) {
+ // Move season 0 (specials) to the end
+ val sortedSeasons = remember(seasons) {
+ val regularSeasons = seasons.filter { it > 0 }.sorted()
+ val specials = seasons.filter { it == 0 }
+ regularSeasons + specials
+ }
+
+ TvLazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 48.dp, vertical = 24.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(sortedSeasons, key = { it }) { season ->
+ val isSelected = season == selectedSeason
+ var isFocused by remember { mutableStateOf(false) }
+
+ Card(
+ onClick = { onSeasonSelected(season) },
+ modifier = Modifier
+ .then(if (isSelected) Modifier.focusRequester(selectedTabFocusRequester) else Modifier)
+ .onFocusChanged {
+ val nowFocused = it.isFocused
+ isFocused = nowFocused
+ if (nowFocused && !isSelected) {
+ onSeasonSelected(season)
+ }
+ },
+ shape = CardDefaults.shape(
+ shape = RoundedCornerShape(20.dp)
+ ),
+ colors = CardDefaults.colors(
+ containerColor = if (isSelected) NuvioColors.SurfaceVariant else NuvioColors.BackgroundCard,
+ focusedContainerColor = NuvioColors.Primary
+ ),
+ border = CardDefaults.border(
+ focusedBorder = Border(
+ border = BorderStroke(2.dp, NuvioColors.FocusRing),
+ shape = RoundedCornerShape(20.dp)
+ )
+ ),
+ scale = CardDefaults.scale(focusedScale = 1.0f)
+ ) {
+ Text(
+ text = if (season == 0) "Specials" else "Season $season",
+ style = MaterialTheme.typography.titleMedium,
+ color = when {
+ isFocused -> NuvioColors.OnPrimary
+ isSelected -> NuvioColors.TextPrimary
+ else -> NuvioTheme.extendedColors.textSecondary
+ },
+ modifier = Modifier.padding(vertical = 10.dp, horizontal = 20.dp)
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun EpisodesRow(
+ episodes: List,
+ episodeProgressMap: Map, com.nuvio.tv.domain.model.WatchProgress> = emptyMap(),
+ onEpisodeClick: (Video) -> Unit,
+ upFocusRequester: FocusRequester
+) {
+ TvLazyRow(
+ modifier = Modifier
+ .fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ items(episodes, key = { it.id }) { episode ->
+ val progress = episode.season?.let { s ->
+ episode.episode?.let { e ->
+ episodeProgressMap[s to e]
+ }
+ }
+ EpisodeCard(
+ episode = episode,
+ watchProgress = progress,
+ onClick = { onEpisodeClick(episode) },
+ upFocusRequester = upFocusRequester
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun EpisodeCard(
+ episode: Video,
+ watchProgress: com.nuvio.tv.domain.model.WatchProgress? = null,
+ onClick: () -> Unit,
+ upFocusRequester: FocusRequester
+) {
+ val formattedDate = remember(episode.released) {
+ episode.released?.let { formatReleaseDate(it) } ?: ""
+ }
+
+ Card(
+ onClick = onClick,
+ modifier = Modifier
+ .width(280.dp)
+ .focusProperties { up = upFocusRequester },
+ shape = CardDefaults.shape(
+ shape = RoundedCornerShape(8.dp)
+ ),
+ colors = CardDefaults.colors(
+ containerColor = NuvioColors.BackgroundCard,
+ focusedContainerColor = NuvioColors.BackgroundCard
+ ),
+ border = CardDefaults.border(
+ focusedBorder = Border(
+ border = BorderStroke(2.dp, NuvioColors.FocusRing),
+ shape = RoundedCornerShape(8.dp)
+ )
+ ),
+ scale = CardDefaults.scale(
+ focusedScale = 1.05f
+ )
+ ) {
+ Column {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(158.dp)
+ .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp))
+ ) {
+ AsyncImage(
+ model = episode.thumbnail,
+ contentDescription = episode.title,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+
+ // Show watched/in-progress indicator
+ val indicatorColor = when {
+ watchProgress?.isCompleted() == true -> NuvioColors.Primary.copy(alpha = 0.8f)
+ watchProgress?.isInProgress() == true -> NuvioColors.Primary
+ else -> NuvioColors.Primary
+ }
+
+ val indicatorText = when {
+ watchProgress?.isCompleted() == true -> "✓"
+ watchProgress?.isInProgress() == true -> "◉"
+ else -> "◉"
+ }
+
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(8.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(NuvioColors.Background.copy(alpha = 0.8f))
+ .padding(horizontal = 6.dp, vertical = 2.dp)
+ ) {
+ Text(
+ text = indicatorText,
+ style = MaterialTheme.typography.labelSmall,
+ color = indicatorColor
+ )
+ }
+
+ // Progress bar overlay at bottom of thumbnail
+ watchProgress?.let { progress ->
+ if (progress.isInProgress()) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .fillMaxWidth()
+ .height(4.dp)
+ .background(NuvioColors.Background.copy(alpha = 0.5f))
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(progress.progressPercentage)
+ .height(4.dp)
+ .background(NuvioColors.Primary)
+ )
+ }
+ }
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "S${episode.season?.toString()?.padStart(2, '0')}E${episode.episode?.toString()?.padStart(2, '0')} - $formattedDate",
+ style = MaterialTheme.typography.labelSmall,
+ color = NuvioTheme.extendedColors.textSecondary
+ )
+
+ episode.runtime?.let { runtime ->
+ Text(
+ text = "${runtime}m",
+ style = MaterialTheme.typography.labelSmall,
+ color = NuvioTheme.extendedColors.textTertiary
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = episode.title,
+ style = MaterialTheme.typography.titleSmall,
+ color = NuvioColors.TextPrimary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+
+ episode.overview?.let { overview ->
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = overview,
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioTheme.extendedColors.textSecondary,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt
new file mode 100644
index 00000000..e37d0519
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt
@@ -0,0 +1,337 @@
+package com.nuvio.tv.ui.screens.detail
+
+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.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+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.onFocusChanged
+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.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Icon
+import androidx.tv.material3.IconButton
+import androidx.tv.material3.IconButtonDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import coil.compose.AsyncImage
+import com.nuvio.tv.domain.model.Meta
+import com.nuvio.tv.domain.model.Video
+import com.nuvio.tv.ui.theme.NuvioColors
+import com.nuvio.tv.ui.theme.NuvioTheme
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.ui.platform.LocalContext
+import coil.decode.SvgDecoder
+import coil.request.ImageRequest
+import com.nuvio.tv.domain.model.NextToWatch
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun HeroContentSection(
+ meta: Meta,
+ nextEpisode: Video?,
+ nextToWatch: NextToWatch?,
+ onPlayClick: () -> Unit,
+ isInLibrary: Boolean,
+ onToggleLibrary: () -> Unit
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(540.dp),
+ verticalArrangement = Arrangement.Bottom
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 48.dp, end = 48.dp, bottom = 16.dp),
+ verticalArrangement = Arrangement.Bottom
+ ) {
+ if (meta.logo != null) {
+ AsyncImage(
+ model = meta.logo,
+ contentDescription = meta.name,
+ modifier = Modifier
+ .height(100.dp)
+ .fillMaxWidth(0.4f)
+ .padding(bottom = 16.dp),
+ contentScale = ContentScale.Fit,
+ alignment = Alignment.CenterStart
+ )
+ } else {
+ Text(
+ text = meta.name,
+ style = MaterialTheme.typography.displayMedium,
+ color = NuvioColors.TextPrimary,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ PlayButton(
+ text = nextToWatch?.displayText ?: when {
+ nextEpisode != null -> "Play S${nextEpisode.season}, E${nextEpisode.episode}"
+ else -> "Play"
+ },
+ onClick = onPlayClick
+ )
+
+ ActionIconButton(
+ icon = if (isInLibrary) Icons.Default.Check else Icons.Default.Add,
+ contentDescription = if (isInLibrary) "Remove from library" else "Add to library",
+ onClick = onToggleLibrary
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Director/Writer line above description
+ val directorLine = meta.director.takeIf { it.isNotEmpty() }?.joinToString(", ")
+ val writerLine = meta.writer.takeIf { it.isNotEmpty() }?.joinToString(", ")
+ val creditLine = if (!directorLine.isNullOrBlank()) {
+ "Director: $directorLine"
+ } else if (!writerLine.isNullOrBlank()) {
+ "Writer: $writerLine"
+ } else {
+ null
+ }
+
+ if (!creditLine.isNullOrBlank()) {
+ Text(
+ text = creditLine,
+ style = MaterialTheme.typography.labelLarge,
+ color = NuvioTheme.extendedColors.textSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.fillMaxWidth(0.6f)
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ }
+
+ // Always show series/movie description, not episode description
+ if (meta.description != null) {
+ Text(
+ text = meta.description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = NuvioColors.TextPrimary,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .fillMaxWidth(0.6f)
+ .padding(bottom = 12.dp)
+ )
+ }
+
+ MetaInfoRow(meta = meta)
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun PlayButton(
+ text: String,
+ onClick: () -> Unit
+) {
+ var isFocused by remember { mutableStateOf(false) }
+
+ Button(
+ onClick = onClick,
+ modifier = Modifier
+ .onFocusChanged { isFocused = it.isFocused },
+ colors = ButtonDefaults.colors(
+ containerColor = androidx.compose.ui.graphics.Color.White,
+ focusedContainerColor = androidx.compose.ui.graphics.Color(0xFFD0D0D0),
+ contentColor = androidx.compose.ui.graphics.Color.Black,
+ focusedContentColor = androidx.compose.ui.graphics.Color.Black
+ ),
+ shape = ButtonDefaults.shape(
+ shape = RoundedCornerShape(32.dp)
+ ),
+ contentPadding = PaddingValues(horizontal = 24.dp, vertical = 14.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun ActionIconButton(
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ contentDescription: String,
+ onClick: () -> Unit
+) {
+ var isFocused by remember { mutableStateOf(false) }
+
+ IconButton(
+ onClick = onClick,
+ modifier = Modifier
+ .size(48.dp)
+ .onFocusChanged { isFocused = it.isFocused },
+ colors = IconButtonDefaults.colors(
+ containerColor = NuvioColors.BackgroundCard,
+ focusedContainerColor = NuvioColors.Primary,
+ contentColor = NuvioColors.TextPrimary,
+ focusedContentColor = NuvioColors.OnPrimary
+ ),
+ shape = IconButtonDefaults.shape(
+ shape = CircleShape
+ )
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = contentDescription,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun MetaInfoRow(meta: Meta) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ // Primary row: Genres, Runtime, Release, Ratings
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Show all genres
+ if (meta.genres.isNotEmpty()) {
+ Text(
+ text = meta.genres.joinToString(" • "),
+ style = MaterialTheme.typography.labelLarge,
+ color = NuvioTheme.extendedColors.textSecondary
+ )
+ MetaInfoDivider()
+ }
+
+ // Runtime
+ meta.runtime?.let { runtime ->
+ Text(
+ text = formatRuntime(runtime),
+ style = MaterialTheme.typography.labelLarge,
+ color = NuvioTheme.extendedColors.textSecondary
+ )
+ MetaInfoDivider()
+ }
+
+ meta.releaseInfo?.let { releaseInfo ->
+ Text(
+ text = releaseInfo,
+ style = MaterialTheme.typography.labelLarge,
+ color = NuvioTheme.extendedColors.textSecondary
+ )
+ MetaInfoDivider()
+ }
+
+ meta.imdbRating?.let { rating ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ val context = LocalContext.current
+ AsyncImage(
+ model = ImageRequest.Builder(context)
+ .data(com.nuvio.tv.R.raw.imdb_logo_2016)
+ .decoderFactory(SvgDecoder.Factory())
+ .build(),
+ contentDescription = "Rating",
+ modifier = Modifier.size(30.dp),
+ contentScale = ContentScale.Fit
+ )
+ Text(
+ text = String.format("%.1f", rating),
+ style = MaterialTheme.typography.labelLarge,
+ color = NuvioTheme.extendedColors.textSecondary
+ )
+ }
+ }
+ }
+
+ // Secondary row: Country, Language
+ val hasSecondaryInfo = meta.country != null || meta.language != null
+ if (hasSecondaryInfo) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ meta.country?.let { country ->
+ Text(
+ text = country,
+ style = MaterialTheme.typography.labelMedium,
+ color = NuvioTheme.extendedColors.textTertiary
+ )
+ }
+
+ if (meta.country != null && meta.language != null) {
+ MetaInfoDivider()
+ }
+
+ meta.language?.let { language ->
+ Text(
+ text = language.uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ color = NuvioTheme.extendedColors.textTertiary
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun formatRuntime(runtime: String): String {
+ val minutes = runtime.filter { it.isDigit() }.toIntOrNull() ?: return runtime
+ return if (minutes >= 60) {
+ val hours = minutes / 60
+ val mins = minutes % 60
+ if (mins > 0) "${hours}h ${mins}m" else "${hours}h"
+ } else {
+ "${minutes}m"
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun MetaInfoDivider() {
+ Text(
+ text = "•",
+ style = MaterialTheme.typography.labelLarge,
+ color = NuvioTheme.extendedColors.textTertiary
+ )
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt
new file mode 100644
index 00000000..0fee3fe0
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt
@@ -0,0 +1,303 @@
+package com.nuvio.tv.ui.screens.detail
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+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.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.tv.foundation.lazy.list.TvLazyColumn
+import androidx.tv.foundation.lazy.list.rememberTvLazyListState
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import coil.compose.AsyncImage
+import com.nuvio.tv.domain.model.ContentType
+import com.nuvio.tv.domain.model.Meta
+import com.nuvio.tv.domain.model.MetaCastMember
+import com.nuvio.tv.domain.model.NextToWatch
+import com.nuvio.tv.domain.model.Video
+import com.nuvio.tv.domain.model.WatchProgress
+import com.nuvio.tv.ui.components.ErrorState
+import com.nuvio.tv.ui.components.LoadingIndicator
+import com.nuvio.tv.ui.theme.NuvioColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun MetaDetailsScreen(
+ viewModel: MetaDetailsViewModel = hiltViewModel(),
+ onBackPress: () -> Unit,
+ onPlayClick: (
+ videoId: String,
+ contentType: String,
+ contentId: String,
+ title: String,
+ poster: String?,
+ backdrop: String?,
+ logo: String?,
+ season: Int?,
+ episode: Int?,
+ episodeName: String?,
+ genres: String?,
+ year: String?
+ ) -> Unit = { _, _, _, _, _, _, _, _, _, _, _, _ -> }
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ BackHandler {
+ onBackPress()
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(NuvioColors.Background)
+ ) {
+ when {
+ uiState.isLoading -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ LoadingIndicator()
+ }
+ }
+ uiState.error != null -> {
+ ErrorState(
+ message = uiState.error ?: "An error occurred",
+ onRetry = { viewModel.onEvent(MetaDetailsEvent.OnRetry) }
+ )
+ }
+ uiState.meta != null -> {
+ val meta = uiState.meta!!
+ val genresString = meta.genres.takeIf { it.isNotEmpty() }?.joinToString(" • ")
+ val yearString = meta.releaseInfo
+
+ MetaDetailsContent(
+ meta = meta,
+ seasons = uiState.seasons,
+ selectedSeason = uiState.selectedSeason,
+ episodesForSeason = uiState.episodesForSeason,
+ isInLibrary = uiState.isInLibrary,
+ nextToWatch = uiState.nextToWatch,
+ episodeProgressMap = uiState.episodeProgressMap,
+ onSeasonSelected = { viewModel.onEvent(MetaDetailsEvent.OnSeasonSelected(it)) },
+ onEpisodeClick = { video ->
+ // Navigate to stream screen for episode
+ onPlayClick(
+ video.id,
+ meta.type.toApiString(),
+ meta.id,
+ meta.name,
+ video.thumbnail ?: meta.poster,
+ meta.background,
+ meta.logo,
+ video.season,
+ video.episode,
+ video.title,
+ null,
+ null
+ )
+ },
+ onPlayClick = { videoId ->
+ // Navigate to stream screen for movie
+ onPlayClick(
+ videoId,
+ meta.type.toApiString(),
+ meta.id,
+ meta.name,
+ meta.poster,
+ meta.background,
+ meta.logo,
+ null,
+ null,
+ null,
+ genresString,
+ yearString
+ )
+ },
+ onToggleLibrary = { viewModel.onEvent(MetaDetailsEvent.OnToggleLibrary) }
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun MetaDetailsContent(
+ meta: Meta,
+ seasons: List,
+ selectedSeason: Int,
+ episodesForSeason: List,
+ isInLibrary: Boolean,
+ nextToWatch: NextToWatch?,
+ episodeProgressMap: Map, WatchProgress>,
+ onSeasonSelected: (Int) -> Unit,
+ onEpisodeClick: (Video) -> Unit,
+ onPlayClick: (String) -> Unit,
+ onToggleLibrary: () -> Unit
+) {
+ val isSeries = meta.type == ContentType.SERIES || meta.videos.isNotEmpty()
+ val nextEpisode = episodesForSeason.firstOrNull()
+ val listState = rememberTvLazyListState()
+ val selectedSeasonFocusRequester = remember { FocusRequester() }
+
+ // Track if scrolled past hero (first item)
+ val isScrolledPastHero by remember {
+ derivedStateOf {
+ listState.firstVisibleItemIndex > 0 ||
+ (listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset > 200)
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Sticky background image - stays fixed in place while content scrolls
+ Box(modifier = Modifier.fillMaxSize()) {
+ AsyncImage(
+ model = meta.background ?: meta.poster,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+
+ // Light global dim so text remains readable
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(NuvioColors.Background.copy(alpha = 0.08f))
+ )
+
+ // Left side gradient fade for text readability
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.horizontalGradient(
+ colorStops = arrayOf(
+ 0.0f to NuvioColors.Background,
+ 0.20f to NuvioColors.Background.copy(alpha = 0.95f),
+ 0.35f to NuvioColors.Background.copy(alpha = 0.8f),
+ 0.45f to NuvioColors.Background.copy(alpha = 0.6f),
+ 0.55f to NuvioColors.Background.copy(alpha = 0.4f),
+ 0.65f to NuvioColors.Background.copy(alpha = 0.2f),
+ 0.75f to Color.Transparent,
+ 1.0f to Color.Transparent
+ )
+ )
+ )
+ )
+
+ // Bottom gradient when scrolled past hero
+ if (isScrolledPastHero) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colorStops = arrayOf(
+ 0.0f to Color.Transparent,
+ 0.5f to Color.Transparent,
+ 0.7f to NuvioColors.Background.copy(alpha = 0.5f),
+ 0.85f to NuvioColors.Background.copy(alpha = 0.8f),
+ 1.0f to NuvioColors.Background
+ )
+ )
+ )
+ )
+ }
+ }
+
+ // Single scrollable column with hero + content
+ TvLazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ state = listState
+ ) {
+ // Hero as first item in the lazy column
+ item {
+ HeroContentSection(
+ meta = meta,
+ nextEpisode = nextEpisode,
+ nextToWatch = nextToWatch,
+ onPlayClick = {
+ // Use nextToWatch's video ID if available, otherwise fall back to logic
+ val videoId = nextToWatch?.nextVideoId ?: if (isSeries && nextEpisode != null) {
+ nextEpisode.id
+ } else {
+ meta.id
+ }
+ onPlayClick(videoId)
+ },
+ isInLibrary = isInLibrary,
+ onToggleLibrary = onToggleLibrary
+ )
+ }
+
+ // Season tabs and episodes for series
+ if (isSeries && seasons.isNotEmpty()) {
+ item {
+ SeasonTabs(
+ seasons = seasons,
+ selectedSeason = selectedSeason,
+ onSeasonSelected = onSeasonSelected,
+ selectedTabFocusRequester = selectedSeasonFocusRequester
+ )
+ }
+ item {
+ EpisodesRow(
+ episodes = episodesForSeason,
+ episodeProgressMap = episodeProgressMap,
+ onEpisodeClick = onEpisodeClick,
+ upFocusRequester = selectedSeasonFocusRequester
+ )
+ }
+ }
+
+ // Cast section below episodes
+ val castMembersToShow = if (meta.castMembers.isNotEmpty()) {
+ meta.castMembers
+ } else {
+ meta.cast.map { name -> MetaCastMember(name = name) }
+ }
+
+ if (castMembersToShow.isNotEmpty()) {
+ item {
+ CastSection(cast = castMembersToShow)
+ }
+ }
+
+ if (meta.productionCompanies.isNotEmpty()) {
+ item {
+ CompanyLogosSection(
+ title = "Production",
+ companies = meta.productionCompanies
+ )
+ }
+ }
+
+ if (meta.networks.isNotEmpty()) {
+ item {
+ CompanyLogosSection(
+ title = "Network",
+ companies = meta.networks
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsUiState.kt
new file mode 100644
index 00000000..787e5404
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsUiState.kt
@@ -0,0 +1,27 @@
+package com.nuvio.tv.ui.screens.detail
+
+import com.nuvio.tv.domain.model.Meta
+import com.nuvio.tv.domain.model.NextToWatch
+import com.nuvio.tv.domain.model.Video
+import com.nuvio.tv.domain.model.WatchProgress
+
+data class MetaDetailsUiState(
+ val isLoading: Boolean = true,
+ val meta: Meta? = null,
+ val error: String? = null,
+ val selectedSeason: Int = 1,
+ val seasons: List = emptyList(),
+ val episodesForSeason: List = emptyList(),
+ val isInLibrary: Boolean = false,
+ val nextToWatch: NextToWatch? = null,
+ val episodeProgressMap: Map, WatchProgress> = emptyMap()
+)
+
+sealed class MetaDetailsEvent {
+ data class OnSeasonSelected(val season: Int) : MetaDetailsEvent()
+ data class OnEpisodeClick(val video: Video) : MetaDetailsEvent()
+ data object OnPlayClick : MetaDetailsEvent()
+ data object OnToggleLibrary : MetaDetailsEvent()
+ data object OnRetry : MetaDetailsEvent()
+ data object OnBackPress : MetaDetailsEvent()
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt
new file mode 100644
index 00000000..46e08740
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt
@@ -0,0 +1,414 @@
+package com.nuvio.tv.ui.screens.detail
+
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.core.tmdb.TmdbMetadataService
+import com.nuvio.tv.core.tmdb.TmdbService
+import com.nuvio.tv.data.local.LibraryPreferences
+import com.nuvio.tv.data.local.TmdbSettingsDataStore
+import com.nuvio.tv.domain.model.Meta
+import com.nuvio.tv.domain.model.NextToWatch
+import com.nuvio.tv.domain.model.SavedLibraryItem
+import com.nuvio.tv.domain.model.Video
+import com.nuvio.tv.domain.model.WatchProgress
+import com.nuvio.tv.domain.repository.MetaRepository
+import com.nuvio.tv.domain.repository.WatchProgressRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class MetaDetailsViewModel @Inject constructor(
+ private val metaRepository: MetaRepository,
+ private val tmdbSettingsDataStore: TmdbSettingsDataStore,
+ private val tmdbService: TmdbService,
+ private val tmdbMetadataService: TmdbMetadataService,
+ private val libraryPreferences: LibraryPreferences,
+ private val watchProgressRepository: WatchProgressRepository,
+ savedStateHandle: SavedStateHandle
+) : ViewModel() {
+
+ private val itemId: String = savedStateHandle["itemId"] ?: ""
+ private val itemType: String = savedStateHandle["itemType"] ?: ""
+ private val preferredAddonBaseUrl: String? = savedStateHandle["addonBaseUrl"]
+
+ private val _uiState = MutableStateFlow(MetaDetailsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ observeLibraryState()
+ observeWatchProgress()
+ loadMeta()
+ }
+
+ fun onEvent(event: MetaDetailsEvent) {
+ when (event) {
+ is MetaDetailsEvent.OnSeasonSelected -> selectSeason(event.season)
+ is MetaDetailsEvent.OnEpisodeClick -> { /* Navigate to stream */ }
+ MetaDetailsEvent.OnPlayClick -> { /* Start playback */ }
+ MetaDetailsEvent.OnToggleLibrary -> toggleLibrary()
+ MetaDetailsEvent.OnRetry -> loadMeta()
+ MetaDetailsEvent.OnBackPress -> { /* Handle in screen */ }
+ }
+ }
+
+ private fun observeLibraryState() {
+ viewModelScope.launch {
+ libraryPreferences.isInLibrary(itemId = itemId, itemType = itemType)
+ .collectLatest { inLibrary ->
+ _uiState.update { it.copy(isInLibrary = inLibrary) }
+ }
+ }
+ }
+
+ private fun observeWatchProgress() {
+ viewModelScope.launch {
+ watchProgressRepository.getAllEpisodeProgress(itemId).collectLatest { progressMap ->
+ _uiState.update { it.copy(episodeProgressMap = progressMap) }
+ // Recalculate next to watch when progress changes
+ calculateNextToWatch()
+ }
+ }
+ }
+
+ private fun loadMeta() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+
+ // 1) Prefer meta from the originating addon (same catalog source)
+ val preferred = preferredAddonBaseUrl?.takeIf { it.isNotBlank() }
+ val preferredMeta: Meta? = preferred?.let { baseUrl ->
+ when (val result = metaRepository.getMeta(addonBaseUrl = baseUrl, type = itemType, id = itemId)
+ .first { it !is NetworkResult.Loading }) {
+ is NetworkResult.Success -> result.data
+ is NetworkResult.Error -> null
+ NetworkResult.Loading -> null
+ }
+ }
+
+ if (preferredMeta != null) {
+ applyMetaWithEnrichment(preferredMeta)
+ return@launch
+ }
+
+ // 2) Fallback: first addon that can provide meta (often Cinemeta)
+ metaRepository.getMetaFromAllAddons(type = itemType, id = itemId).collect { result ->
+ when (result) {
+ is NetworkResult.Success -> applyMetaWithEnrichment(result.data)
+ is NetworkResult.Error -> {
+ _uiState.update { it.copy(isLoading = false, error = result.message) }
+ }
+ NetworkResult.Loading -> {
+ _uiState.update { it.copy(isLoading = true) }
+ }
+ }
+ }
+ }
+ }
+
+ private fun applyMeta(meta: Meta) {
+ val seasons = meta.videos
+ .mapNotNull { it.season }
+ .distinct()
+ .sorted()
+
+ // Prefer first regular season (> 0), fallback to season 0 (specials)
+ val selectedSeason = seasons.firstOrNull { it > 0 } ?: seasons.firstOrNull() ?: 1
+ val episodesForSeason = getEpisodesForSeason(meta.videos, selectedSeason)
+
+ _uiState.update {
+ it.copy(
+ isLoading = false,
+ meta = meta,
+ seasons = seasons,
+ selectedSeason = selectedSeason,
+ episodesForSeason = episodesForSeason,
+ error = null
+ )
+ }
+
+ // Calculate next to watch after meta is loaded
+ calculateNextToWatch()
+ }
+
+ private suspend fun applyMetaWithEnrichment(meta: Meta) {
+ val enriched = enrichMeta(meta)
+ applyMeta(enriched)
+ }
+
+ private suspend fun enrichMeta(meta: Meta): Meta {
+ val settings = tmdbSettingsDataStore.settings.first()
+ if (!settings.enabled) return meta
+
+ val tmdbId = tmdbService.ensureTmdbId(meta.id, meta.type.toApiString())
+ ?: tmdbService.ensureTmdbId(itemId, itemType)
+ ?: return meta
+
+ val enrichment = tmdbMetadataService.fetchEnrichment(tmdbId, meta.type)
+
+ var updated = meta
+
+ // Group: Artwork (logo, backdrop)
+ if (enrichment != null && settings.useArtwork) {
+ updated = updated.copy(
+ background = enrichment.backdrop ?: updated.background,
+ logo = enrichment.logo ?: updated.logo
+ )
+ }
+
+ // Group: Basic Info (description, genres, rating)
+ if (enrichment != null && settings.useBasicInfo) {
+ updated = updated.copy(description = enrichment.description ?: updated.description)
+ if (enrichment.genres.isNotEmpty()) {
+ updated = updated.copy(genres = enrichment.genres)
+ }
+ updated = updated.copy(imdbRating = enrichment.rating?.toFloat() ?: updated.imdbRating)
+ }
+
+ // Group: Details (runtime, release info, country, language)
+ if (enrichment != null && settings.useDetails) {
+ updated = updated.copy(
+ runtime = enrichment.runtimeMinutes?.toString() ?: updated.runtime,
+ releaseInfo = enrichment.releaseInfo ?: updated.releaseInfo,
+ country = enrichment.countries?.joinToString(", ") ?: updated.country,
+ language = enrichment.language ?: updated.language
+ )
+ }
+
+ // Group: Credits (cast with photos, director, writer)
+ if (enrichment != null && settings.useCredits) {
+ if (enrichment.castMembers.isNotEmpty()) {
+ updated = updated.copy(
+ castMembers = enrichment.castMembers,
+ cast = enrichment.castMembers.map { it.name }
+ )
+ }
+ updated = updated.copy(
+ director = if (enrichment.director.isNotEmpty()) enrichment.director else updated.director,
+ writer = if (enrichment.writer.isNotEmpty()) enrichment.writer else updated.writer
+ )
+ }
+
+ // Group: Productions
+ if (enrichment != null && settings.useProductions && enrichment.productionCompanies.isNotEmpty()) {
+ updated = updated.copy(productionCompanies = enrichment.productionCompanies)
+ }
+
+ // Group: Networks
+ if (enrichment != null && settings.useNetworks && enrichment.networks.isNotEmpty()) {
+ updated = updated.copy(networks = enrichment.networks)
+ }
+
+ // Group: Episodes (titles, overviews, thumbnails, runtime)
+ if (settings.useEpisodes && meta.type.toApiString() in listOf("series", "tv")) {
+ val seasonNumbers = meta.videos.mapNotNull { it.season }.distinct()
+ val episodeMap = tmdbMetadataService.fetchEpisodeEnrichment(tmdbId, seasonNumbers)
+ if (episodeMap.isNotEmpty()) {
+ updated = updated.copy(
+ videos = meta.videos.map { video ->
+ val season = video.season
+ val episode = video.episode
+ val key = if (season != null && episode != null) season to episode else null
+ val ep = key?.let { episodeMap[it] }
+
+ video.copy(
+ title = ep?.title ?: video.title,
+ overview = ep?.overview ?: video.overview,
+ released = ep?.airDate ?: video.released,
+ thumbnail = ep?.thumbnail ?: video.thumbnail,
+ runtime = ep?.runtimeMinutes
+ )
+ }
+ )
+ }
+ }
+
+ return updated
+ }
+
+ private fun selectSeason(season: Int) {
+ val episodes = _uiState.value.meta?.videos?.let { getEpisodesForSeason(it, season) } ?: emptyList()
+ _uiState.update {
+ it.copy(
+ selectedSeason = season,
+ episodesForSeason = episodes
+ )
+ }
+ }
+
+ private fun getEpisodesForSeason(videos: List, season: Int): List {
+ return videos
+ .filter { it.season == season }
+ .sortedBy { it.episode }
+ }
+
+ private fun calculateNextToWatch() {
+ val meta = _uiState.value.meta ?: return
+ val progressMap = _uiState.value.episodeProgressMap
+ val isSeries = meta.type.toApiString() in listOf("series", "tv")
+
+ if (!isSeries) {
+ // For movies, check if there's an in-progress watch
+ viewModelScope.launch {
+ val progress = watchProgressRepository.getProgress(itemId).first()
+ val nextToWatch = if (progress != null && progress.isInProgress()) {
+ NextToWatch(
+ watchProgress = progress,
+ isResume = true,
+ nextVideoId = meta.id,
+ nextSeason = null,
+ nextEpisode = null,
+ displayText = "Resume"
+ )
+ } else {
+ NextToWatch(
+ watchProgress = null,
+ isResume = false,
+ nextVideoId = meta.id,
+ nextSeason = null,
+ nextEpisode = null,
+ displayText = "Play"
+ )
+ }
+ _uiState.update { it.copy(nextToWatch = nextToWatch) }
+ }
+ return
+ }
+
+ // For series, find the next episode to watch
+ val allEpisodes = meta.videos
+ .filter { it.season != null && it.episode != null }
+ .sortedWith(compareBy({ it.season }, { it.episode }))
+
+ if (allEpisodes.isEmpty()) {
+ _uiState.update {
+ it.copy(nextToWatch = NextToWatch(
+ watchProgress = null,
+ isResume = false,
+ nextVideoId = meta.id,
+ nextSeason = null,
+ nextEpisode = null,
+ displayText = "Play"
+ ))
+ }
+ return
+ }
+
+ // Find the last watched episode that's in progress or find the next unwatched
+ var resumeEpisode: Video? = null
+ var resumeProgress: WatchProgress? = null
+ var nextUnwatchedEpisode: Video? = null
+
+ for (episode in allEpisodes) {
+ val season = episode.season ?: continue
+ val ep = episode.episode ?: continue
+ val progress = progressMap[season to ep]
+
+ if (progress != null) {
+ if (progress.isInProgress()) {
+ // Found an episode in progress - this is the one to resume
+ resumeEpisode = episode
+ resumeProgress = progress
+ break
+ } else if (progress.isCompleted()) {
+ // This episode is completed, look for the next one
+ continue
+ }
+ } else {
+ // No progress for this episode - it's the next unwatched
+ if (nextUnwatchedEpisode == null) {
+ nextUnwatchedEpisode = episode
+ }
+ // If we haven't found a resume episode yet and this is first unwatched
+ if (resumeEpisode == null) {
+ break
+ }
+ }
+ }
+
+ val nextToWatch = when {
+ resumeEpisode != null && resumeProgress != null -> {
+ // Resume the in-progress episode
+ NextToWatch(
+ watchProgress = resumeProgress,
+ isResume = true,
+ nextVideoId = resumeEpisode.id,
+ nextSeason = resumeEpisode.season,
+ nextEpisode = resumeEpisode.episode,
+ displayText = "Resume S${resumeEpisode.season}E${resumeEpisode.episode}"
+ )
+ }
+ nextUnwatchedEpisode != null -> {
+ // Play the next unwatched episode
+ val hasWatchedSomething = progressMap.isNotEmpty()
+ val displayPrefix = if (hasWatchedSomething) "Next" else "Play"
+ NextToWatch(
+ watchProgress = null,
+ isResume = false,
+ nextVideoId = nextUnwatchedEpisode.id,
+ nextSeason = nextUnwatchedEpisode.season,
+ nextEpisode = nextUnwatchedEpisode.episode,
+ displayText = "$displayPrefix S${nextUnwatchedEpisode.season}E${nextUnwatchedEpisode.episode}"
+ )
+ }
+ else -> {
+ // All episodes watched or start from beginning
+ val firstEpisode = allEpisodes.firstOrNull()
+ NextToWatch(
+ watchProgress = null,
+ isResume = false,
+ nextVideoId = firstEpisode?.id ?: meta.id,
+ nextSeason = firstEpisode?.season,
+ nextEpisode = firstEpisode?.episode,
+ displayText = if (firstEpisode != null) {
+ "Play S${firstEpisode.season}E${firstEpisode.episode}"
+ } else {
+ "Play"
+ }
+ )
+ }
+ }
+
+ _uiState.update { it.copy(nextToWatch = nextToWatch) }
+ }
+
+ private fun toggleLibrary() {
+ val meta = _uiState.value.meta ?: return
+ viewModelScope.launch {
+ if (_uiState.value.isInLibrary) {
+ libraryPreferences.removeItem(itemId = meta.id, itemType = meta.type.toApiString())
+ } else {
+ libraryPreferences.addItem(meta.toSavedLibraryItem(preferredAddonBaseUrl))
+ }
+ }
+ }
+
+ private fun Meta.toSavedLibraryItem(addonBaseUrl: String?): SavedLibraryItem {
+ return SavedLibraryItem(
+ id = id,
+ type = type.toApiString(),
+ name = name,
+ poster = poster,
+ posterShape = posterShape,
+ background = background,
+ description = description,
+ releaseInfo = releaseInfo,
+ imdbRating = imdbRating,
+ genres = genres,
+ addonBaseUrl = addonBaseUrl
+ )
+ }
+
+ fun getNextEpisodeInfo(): String? {
+ val nextToWatch = _uiState.value.nextToWatch
+ return nextToWatch?.displayText
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt
new file mode 100644
index 00000000..15039c35
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt
@@ -0,0 +1,102 @@
+package com.nuvio.tv.ui.screens.home
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.foundation.lazy.rememberLazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import com.nuvio.tv.ui.components.CatalogRowSection
+import com.nuvio.tv.ui.components.ContinueWatchingSection
+import com.nuvio.tv.ui.components.ErrorState
+import com.nuvio.tv.ui.components.LoadingIndicator
+import com.nuvio.tv.ui.theme.NuvioColors
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun HomeScreen(
+ viewModel: HomeViewModel = hiltViewModel(),
+ onNavigateToDetail: (String, String, String) -> Unit
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ val columnListState = rememberLazyListState()
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(NuvioColors.Background)
+ ) {
+ when {
+ uiState.isLoading && uiState.catalogRows.isEmpty() -> {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ LoadingIndicator()
+ }
+ }
+ uiState.error != null && uiState.catalogRows.isEmpty() -> {
+ ErrorState(
+ message = uiState.error ?: "An error occurred",
+ onRetry = { viewModel.onEvent(HomeEvent.OnRetry) }
+ )
+ }
+ else -> {
+ LazyColumn(
+ state = columnListState,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(32.dp)
+ ) {
+ // Continue Watching section at the top
+ if (uiState.continueWatchingItems.isNotEmpty()) {
+ item(key = "continue_watching") {
+ ContinueWatchingSection(
+ items = uiState.continueWatchingItems,
+ onItemClick = { progress ->
+ onNavigateToDetail(
+ progress.contentId,
+ progress.contentType,
+ "" // No specific addon
+ )
+ }
+ )
+ }
+ }
+
+ itemsIndexed(
+ items = uiState.catalogRows,
+ key = { _, item -> "${item.addonId}_${item.type}_${item.catalogId}" }
+ ) { index, catalogRow ->
+ CatalogRowSection(
+ catalogRow = catalogRow,
+ onItemClick = { id, type, addonBaseUrl ->
+ onNavigateToDetail(id, type, addonBaseUrl)
+ },
+ onLoadMore = {
+ viewModel.onEvent(
+ HomeEvent.OnLoadMoreCatalog(
+ catalogId = catalogRow.catalogId,
+ addonId = catalogRow.addonId,
+ type = catalogRow.type.toApiString()
+ )
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt
new file mode 100644
index 00000000..81194a22
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt
@@ -0,0 +1,18 @@
+package com.nuvio.tv.ui.screens.home
+
+import com.nuvio.tv.domain.model.CatalogRow
+import com.nuvio.tv.domain.model.WatchProgress
+
+data class HomeUiState(
+ val catalogRows: List = emptyList(),
+ val continueWatchingItems: List = emptyList(),
+ val isLoading: Boolean = true,
+ val error: String? = null,
+ val selectedItemId: String? = null
+)
+
+sealed class HomeEvent {
+ data class OnItemClick(val itemId: String, val itemType: String) : HomeEvent()
+ data class OnLoadMoreCatalog(val catalogId: String, val addonId: String, val type: String) : HomeEvent()
+ data object OnRetry : HomeEvent()
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt
new file mode 100644
index 00000000..b04e2af6
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt
@@ -0,0 +1,193 @@
+package com.nuvio.tv.ui.screens.home
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.nuvio.tv.core.network.NetworkResult
+import com.nuvio.tv.domain.model.Addon
+import com.nuvio.tv.domain.model.CatalogDescriptor
+import com.nuvio.tv.domain.model.CatalogRow
+import com.nuvio.tv.domain.model.WatchProgress
+import com.nuvio.tv.domain.repository.AddonRepository
+import com.nuvio.tv.domain.repository.CatalogRepository
+import com.nuvio.tv.domain.repository.WatchProgressRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class HomeViewModel @Inject constructor(
+ private val addonRepository: AddonRepository,
+ private val catalogRepository: CatalogRepository,
+ private val watchProgressRepository: WatchProgressRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(HomeUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val catalogsMap = linkedMapOf()
+ private val catalogOrder = mutableListOf()
+
+ init {
+ loadContinueWatching()
+ loadAllCatalogs()
+ }
+
+ fun onEvent(event: HomeEvent) {
+ when (event) {
+ is HomeEvent.OnItemClick -> navigateToDetail(event.itemId, event.itemType)
+ is HomeEvent.OnLoadMoreCatalog -> loadMoreCatalogItems(event.catalogId, event.addonId, event.type)
+ HomeEvent.OnRetry -> loadAllCatalogs()
+ }
+ }
+
+ private fun loadContinueWatching() {
+ viewModelScope.launch {
+ watchProgressRepository.continueWatching.collectLatest { items ->
+ _uiState.update { it.copy(continueWatchingItems = items) }
+ }
+ }
+ }
+
+ private fun loadAllCatalogs() {
+ viewModelScope.launch {
+ _uiState.update { it.copy(isLoading = true, error = null) }
+ catalogOrder.clear()
+ catalogsMap.clear()
+
+ try {
+ val addons = addonRepository.getInstalledAddons().first()
+
+ if (addons.isEmpty()) {
+ _uiState.update { it.copy(isLoading = false, error = "No addons installed") }
+ return@launch
+ }
+
+ // Build catalog order based on addon manifest order
+ addons.forEach { addon ->
+ addon.catalogs
+ .filterNot { it.isSearchOnlyCatalog() }
+ .forEach { catalog ->
+ val key = catalogKey(
+ addonId = addon.id,
+ type = catalog.type.toApiString(),
+ catalogId = catalog.id
+ )
+ if (key !in catalogOrder) {
+ catalogOrder.add(key)
+ }
+ }
+ }
+
+ // Load catalogs
+ addons.forEach { addon ->
+ addon.catalogs
+ .filterNot { it.isSearchOnlyCatalog() }
+ .forEach { catalog ->
+ loadCatalog(addon, catalog)
+ }
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(isLoading = false, error = e.message) }
+ }
+ }
+ }
+
+ private fun loadCatalog(addon: Addon, catalog: CatalogDescriptor) {
+ viewModelScope.launch {
+ catalogRepository.getCatalog(
+ addonBaseUrl = addon.baseUrl,
+ addonId = addon.id,
+ addonName = addon.name,
+ catalogId = catalog.id,
+ catalogName = catalog.name,
+ type = catalog.type.toApiString(),
+ skip = 0
+ ).collect { result ->
+ when (result) {
+ is NetworkResult.Success -> {
+ val key = catalogKey(
+ addonId = addon.id,
+ type = catalog.type.toApiString(),
+ catalogId = catalog.id
+ )
+ catalogsMap[key] = result.data
+ updateCatalogRows()
+ }
+ is NetworkResult.Error -> {
+ // Log error but don't fail entire screen
+ }
+ NetworkResult.Loading -> { /* Handled by individual row */ }
+ }
+ }
+ }
+ }
+
+ private fun loadMoreCatalogItems(catalogId: String, addonId: String, type: String) {
+ val key = catalogKey(addonId = addonId, type = type, catalogId = catalogId)
+ val currentRow = catalogsMap[key] ?: return
+
+ if (currentRow.isLoading || !currentRow.hasMore) return
+
+ catalogsMap[key] = currentRow.copy(isLoading = true)
+ updateCatalogRows()
+
+ viewModelScope.launch {
+ val addons = addonRepository.getInstalledAddons().first()
+ val addon = addons.find { it.id == addonId } ?: return@launch
+
+ val nextSkip = (currentRow.currentPage + 1) * 100
+ catalogRepository.getCatalog(
+ addonBaseUrl = addon.baseUrl,
+ addonId = addon.id,
+ addonName = addon.name,
+ catalogId = catalogId,
+ catalogName = currentRow.catalogName,
+ type = currentRow.type.toApiString(),
+ skip = nextSkip
+ ).collect { result ->
+ when (result) {
+ is NetworkResult.Success -> {
+ val mergedItems = currentRow.items + result.data.items
+ catalogsMap[key] = result.data.copy(items = mergedItems)
+ updateCatalogRows()
+ }
+ is NetworkResult.Error -> {
+ catalogsMap[key] = currentRow.copy(isLoading = false)
+ updateCatalogRows()
+ }
+ NetworkResult.Loading -> { }
+ }
+ }
+ }
+ }
+
+ private fun updateCatalogRows() {
+ _uiState.update { state ->
+ // Preserve addon manifest order
+ val orderedRows = catalogOrder.mapNotNull { key -> catalogsMap[key] }
+ state.copy(
+ catalogRows = orderedRows,
+ isLoading = false
+ )
+ }
+ }
+
+ private fun navigateToDetail(itemId: String, itemType: String) {
+ _uiState.update { it.copy(selectedItemId = itemId) }
+ }
+
+ private fun catalogKey(addonId: String, type: String, catalogId: String): String {
+ return "${addonId}_${type}_${catalogId}"
+ }
+
+ private fun CatalogDescriptor.isSearchOnlyCatalog(): Boolean {
+ return extra.any { extra -> extra.name == "search" && extra.isRequired }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/library/LibraryScreen.kt
new file mode 100644
index 00000000..d9e63f90
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/library/LibraryScreen.kt
@@ -0,0 +1,148 @@
+package com.nuvio.tv.ui.screens.library
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+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.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.tv.foundation.lazy.list.TvLazyColumn
+import androidx.tv.foundation.lazy.list.TvLazyRow
+import androidx.tv.foundation.lazy.list.items
+import androidx.tv.material3.Border
+import androidx.tv.material3.Card
+import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Text
+import com.nuvio.tv.domain.model.ContentType
+import com.nuvio.tv.ui.components.ContentCard
+import com.nuvio.tv.ui.theme.NuvioColors
+import com.nuvio.tv.ui.theme.NuvioTheme
+
+private enum class LibraryTab(val label: String, val type: ContentType) {
+ Movies("Movies", ContentType.MOVIE),
+ Series("Series", ContentType.SERIES)
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun LibraryScreen(
+ viewModel: LibraryViewModel = hiltViewModel(),
+ onNavigateToDetail: (String, String, String?) -> Unit
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ var selectedTab by rememberSaveable { mutableStateOf(LibraryTab.Movies) }
+
+ val filteredItems = uiState.items.filter {
+ ContentType.fromString(it.type) == selectedTab.type
+ }
+
+ TvLazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(NuvioColors.Background),
+ contentPadding = PaddingValues(vertical = 24.dp),
+ verticalArrangement = Arrangement.spacedBy(20.dp)
+ ) {
+ item {
+ LibraryTabs(
+ selectedTab = selectedTab,
+ onTabSelected = { selectedTab = it }
+ )
+ }
+
+ item {
+ if (filteredItems.isEmpty()) {
+ Text(
+ text = "No ${selectedTab.label.lowercase()} saved yet",
+ style = MaterialTheme.typography.titleMedium,
+ color = NuvioTheme.extendedColors.textSecondary,
+ modifier = Modifier.padding(start = 48.dp)
+ )
+ }
+ }
+
+ if (filteredItems.isNotEmpty()) {
+ item {
+ TvLazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 48.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ items(filteredItems, key = { it.id }) { item ->
+ ContentCard(
+ item = item.toMetaPreview(),
+ onClick = {
+ onNavigateToDetail(item.id, item.type, item.addonBaseUrl)
+ }
+ )
+ }
+ }
+ }
+ }
+
+ item { Spacer(modifier = Modifier.height(12.dp)) }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun LibraryTabs(
+ selectedTab: LibraryTab,
+ onTabSelected: (LibraryTab) -> Unit
+) {
+ TvLazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(horizontal = 48.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(LibraryTab.values().toList(), key = { it.name }) { tab ->
+ var isFocused by remember { mutableStateOf(false) }
+ val isSelected = tab == selectedTab
+
+ Card(
+ onClick = { onTabSelected(tab) },
+ modifier = Modifier.onFocusChanged { isFocused = it.isFocused },
+ shape = CardDefaults.shape(shape = RoundedCornerShape(20.dp)),
+ colors = CardDefaults.colors(
+ containerColor = if (isSelected) NuvioColors.SurfaceVariant else NuvioColors.BackgroundCard,
+ focusedContainerColor = NuvioColors.Primary
+ ),
+ border = CardDefaults.border(
+ focusedBorder = Border(
+ border = BorderStroke(2.dp, NuvioColors.FocusRing),
+ shape = RoundedCornerShape(20.dp)
+ )
+ ),
+ scale = CardDefaults.scale(focusedScale = 1.0f)
+ ) {
+ Text(
+ text = tab.label,
+ style = MaterialTheme.typography.titleMedium,
+ color = when {
+ isFocused -> NuvioColors.OnPrimary
+ isSelected -> NuvioColors.TextPrimary
+ else -> NuvioTheme.extendedColors.textSecondary
+ },
+ modifier = Modifier.padding(vertical = 10.dp, horizontal = 20.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/library/LibraryViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/library/LibraryViewModel.kt
new file mode 100644
index 00000000..09a697fe
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/library/LibraryViewModel.kt
@@ -0,0 +1,30 @@
+package com.nuvio.tv.ui.screens.library
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.nuvio.tv.data.local.LibraryPreferences
+import com.nuvio.tv.domain.model.SavedLibraryItem
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import javax.inject.Inject
+
+@HiltViewModel
+class LibraryViewModel @Inject constructor(
+ libraryPreferences: LibraryPreferences
+) : ViewModel() {
+
+ val uiState: StateFlow = libraryPreferences.libraryItems
+ .map { items -> LibraryUiState(items = items) }
+ .stateIn(
+ viewModelScope,
+ SharingStarted.WhileSubscribed(5_000),
+ LibraryUiState()
+ )
+}
+
+data class LibraryUiState(
+ val items: List = emptyList()
+)
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt
new file mode 100644
index 00000000..8cc9bf13
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt
@@ -0,0 +1,868 @@
+@file:OptIn(ExperimentalTvMaterial3Api::class)
+
+package com.nuvio.tv.ui.screens.player
+
+import android.view.KeyEvent
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+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.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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.ClosedCaption
+import androidx.compose.material.icons.filled.Forward10
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Replay10
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Speed
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+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.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.compose.ui.window.Dialog
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.media3.ui.PlayerView
+import androidx.tv.foundation.lazy.list.TvLazyColumn
+import androidx.tv.foundation.lazy.list.items
+import androidx.tv.material3.Card
+import androidx.tv.material3.CardDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Icon
+import androidx.tv.material3.IconButton
+import androidx.tv.material3.IconButtonDefaults
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Text
+import com.nuvio.tv.ui.components.LoadingIndicator
+import com.nuvio.tv.ui.theme.NuvioColors
+import com.nuvio.tv.ui.theme.NuvioTheme
+import java.util.concurrent.TimeUnit
+
+@Composable
+fun PlayerScreen(
+ viewModel: PlayerViewModel = hiltViewModel(),
+ onBackPress: () -> Unit
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val containerFocusRequester = remember { FocusRequester() }
+ val playPauseFocusRequester = remember { FocusRequester() }
+
+ BackHandler {
+ if (uiState.showControls) {
+ // If controls are visible, hide them instead of going back
+ viewModel.hideControls()
+ } else {
+ // If controls are hidden, go back
+ onBackPress()
+ }
+ }
+
+ // Handle lifecycle events
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_PAUSE -> {
+ viewModel.exoPlayer?.pause()
+ }
+ Lifecycle.Event.ON_RESUME -> {
+ // Don't auto-resume, let user control
+ }
+ else -> {}
+ }
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ }
+ }
+
+ // Request focus for key events when controls are hidden
+ LaunchedEffect(uiState.showControls) {
+ if (uiState.showControls) {
+ // When controls are shown, focus the play/pause button
+ try {
+ playPauseFocusRequester.requestFocus()
+ } catch (e: Exception) {
+ // Focus requester may not be ready yet
+ }
+ } else {
+ // When controls are hidden, focus the container for key events
+ try {
+ containerFocusRequester.requestFocus()
+ } catch (e: Exception) {
+ // Focus requester may not be ready yet
+ }
+ }
+ }
+
+ // Initial focus
+ LaunchedEffect(Unit) {
+ containerFocusRequester.requestFocus()
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black)
+ .focusRequester(containerFocusRequester)
+ .focusable()
+ .onKeyEvent { keyEvent ->
+ if (keyEvent.nativeKeyEvent.action == KeyEvent.ACTION_DOWN) {
+ when (keyEvent.nativeKeyEvent.keyCode) {
+ KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
+ if (!uiState.showControls) {
+ viewModel.onEvent(PlayerEvent.OnToggleControls)
+ true
+ } else {
+ // Let the focused button handle it
+ false
+ }
+ }
+ KeyEvent.KEYCODE_DPAD_RIGHT -> {
+ if (!uiState.showControls) {
+ viewModel.onEvent(PlayerEvent.OnSeekForward)
+ true
+ } else {
+ // Let focus system handle navigation when controls are visible
+ false
+ }
+ }
+ KeyEvent.KEYCODE_DPAD_LEFT -> {
+ if (!uiState.showControls) {
+ viewModel.onEvent(PlayerEvent.OnSeekBackward)
+ true
+ } else {
+ // Let focus system handle navigation when controls are visible
+ false
+ }
+ }
+ KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN -> {
+ if (!uiState.showControls) {
+ viewModel.onEvent(PlayerEvent.OnToggleControls)
+ true
+ } else {
+ // Let focus system handle navigation when controls are visible
+ false
+ }
+ }
+ KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
+ viewModel.onEvent(PlayerEvent.OnPlayPause)
+ true
+ }
+ KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
+ viewModel.onEvent(PlayerEvent.OnSeekForward)
+ true
+ }
+ KeyEvent.KEYCODE_MEDIA_REWIND -> {
+ viewModel.onEvent(PlayerEvent.OnSeekBackward)
+ true
+ }
+ else -> false
+ }
+ } else false
+ }
+ ) {
+ // Video Player
+ viewModel.exoPlayer?.let { player ->
+ AndroidView(
+ factory = { context ->
+ PlayerView(context).apply {
+ this.player = player
+ useController = false
+ setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+
+ // Buffering indicator
+ if (uiState.isBuffering) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ LoadingIndicator()
+ }
+ }
+
+ // Error state
+ if (uiState.error != null) {
+ ErrorOverlay(
+ message = uiState.error!!,
+ onRetry = { viewModel.onEvent(PlayerEvent.OnRetry) },
+ onBack = onBackPress
+ )
+ }
+
+ // Controls overlay
+ AnimatedVisibility(
+ visible = uiState.showControls && uiState.error == null,
+ enter = fadeIn(animationSpec = tween(200)),
+ exit = fadeOut(animationSpec = tween(200))
+ ) {
+ PlayerControlsOverlay(
+ uiState = uiState,
+ playPauseFocusRequester = playPauseFocusRequester,
+ onPlayPause = { viewModel.onEvent(PlayerEvent.OnPlayPause) },
+ onSeekForward = { viewModel.onEvent(PlayerEvent.OnSeekForward) },
+ onSeekBackward = { viewModel.onEvent(PlayerEvent.OnSeekBackward) },
+ onSeekTo = { viewModel.onEvent(PlayerEvent.OnSeekTo(it)) },
+ onShowAudioDialog = { viewModel.onEvent(PlayerEvent.OnShowAudioDialog) },
+ onShowSubtitleDialog = { viewModel.onEvent(PlayerEvent.OnShowSubtitleDialog) },
+ onShowSpeedDialog = { viewModel.onEvent(PlayerEvent.OnShowSpeedDialog) },
+ onBack = onBackPress
+ )
+ }
+
+ // Audio track dialog
+ if (uiState.showAudioDialog) {
+ TrackSelectionDialog(
+ title = "Audio",
+ tracks = uiState.audioTracks,
+ selectedIndex = uiState.selectedAudioTrackIndex,
+ onTrackSelected = { viewModel.onEvent(PlayerEvent.OnSelectAudioTrack(it)) },
+ onDismiss = { viewModel.onEvent(PlayerEvent.OnDismissDialog) }
+ )
+ }
+
+ // Subtitle track dialog
+ if (uiState.showSubtitleDialog) {
+ SubtitleSelectionDialog(
+ tracks = uiState.subtitleTracks,
+ selectedIndex = uiState.selectedSubtitleTrackIndex,
+ onTrackSelected = { viewModel.onEvent(PlayerEvent.OnSelectSubtitleTrack(it)) },
+ onDisableSubtitles = { viewModel.onEvent(PlayerEvent.OnDisableSubtitles) },
+ onDismiss = { viewModel.onEvent(PlayerEvent.OnDismissDialog) }
+ )
+ }
+
+ // Speed dialog
+ if (uiState.showSpeedDialog) {
+ SpeedSelectionDialog(
+ currentSpeed = uiState.playbackSpeed,
+ onSpeedSelected = { viewModel.onEvent(PlayerEvent.OnSetPlaybackSpeed(it)) },
+ onDismiss = { viewModel.onEvent(PlayerEvent.OnDismissDialog) }
+ )
+ }
+ }
+}
+
+@Composable
+private fun PlayerControlsOverlay(
+ uiState: PlayerUiState,
+ playPauseFocusRequester: FocusRequester,
+ onPlayPause: () -> Unit,
+ onSeekForward: () -> Unit,
+ onSeekBackward: () -> Unit,
+ onSeekTo: (Long) -> Unit,
+ onShowAudioDialog: () -> Unit,
+ onShowSubtitleDialog: () -> Unit,
+ onShowSpeedDialog: () -> Unit,
+ onBack: () -> Unit
+) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Top gradient
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(150.dp)
+ .align(Alignment.TopCenter)
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ Color.Black.copy(alpha = 0.7f),
+ Color.Transparent
+ )
+ )
+ )
+ )
+
+ // Bottom gradient
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .align(Alignment.BottomCenter)
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ Color.Transparent,
+ Color.Black.copy(alpha = 0.8f)
+ )
+ )
+ )
+ )
+
+ // Top bar - Title
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp, vertical = 24.dp)
+ .align(Alignment.TopStart),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = uiState.title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = Color.White,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ // Bottom controls
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ .padding(horizontal = 32.dp, vertical = 24.dp)
+ ) {
+ // Progress bar
+ ProgressBar(
+ currentPosition = uiState.currentPosition,
+ duration = uiState.duration,
+ onSeekTo = onSeekTo
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Control buttons row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Left side - Playback controls
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ ControlButton(
+ icon = Icons.Default.Replay10,
+ contentDescription = "Rewind 10 seconds",
+ onClick = onSeekBackward
+ )
+
+ // Play/Pause button (larger)
+ PlayPauseButton(
+ isPlaying = uiState.isPlaying,
+ focusRequester = playPauseFocusRequester,
+ onClick = onPlayPause
+ )
+
+ ControlButton(
+ icon = Icons.Default.Forward10,
+ contentDescription = "Forward 10 seconds",
+ onClick = onSeekForward
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ // Time display
+ Text(
+ text = "${formatTime(uiState.currentPosition)} / ${formatTime(uiState.duration)}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.White.copy(alpha = 0.9f)
+ )
+ }
+
+ // Right side - Settings controls
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Speed indicator
+ if (uiState.playbackSpeed != 1f) {
+ Text(
+ text = "${uiState.playbackSpeed}x",
+ style = MaterialTheme.typography.labelMedium,
+ color = NuvioColors.Primary,
+ modifier = Modifier.padding(end = 4.dp)
+ )
+ }
+
+ ControlButton(
+ icon = Icons.Default.Speed,
+ contentDescription = "Playback speed",
+ onClick = onShowSpeedDialog
+ )
+
+ if (uiState.audioTracks.isNotEmpty()) {
+ ControlButton(
+ icon = Icons.AutoMirrored.Filled.VolumeUp,
+ contentDescription = "Audio tracks",
+ onClick = onShowAudioDialog
+ )
+ }
+
+ if (uiState.subtitleTracks.isNotEmpty()) {
+ ControlButton(
+ icon = Icons.Default.ClosedCaption,
+ contentDescription = "Subtitles",
+ onClick = onShowSubtitleDialog
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlayPauseButton(
+ isPlaying: Boolean,
+ focusRequester: FocusRequester,
+ onClick: () -> Unit
+) {
+ var isFocused by remember { mutableStateOf(false) }
+
+ IconButton(
+ onClick = onClick,
+ modifier = Modifier
+ .size(64.dp)
+ .focusRequester(focusRequester)
+ .onFocusChanged { isFocused = it.isFocused },
+ colors = IconButtonDefaults.colors(
+ containerColor = Color.White.copy(alpha = 0.2f),
+ focusedContainerColor = NuvioColors.Primary,
+ contentColor = Color.White,
+ focusedContentColor = NuvioColors.OnPrimary
+ ),
+ shape = IconButtonDefaults.shape(shape = CircleShape)
+ ) {
+ Icon(
+ imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = if (isPlaying) "Pause" else "Play",
+ modifier = Modifier.size(36.dp)
+ )
+ }
+}
+
+@Composable
+private fun ControlButton(
+ icon: ImageVector,
+ contentDescription: String,
+ onClick: () -> Unit
+) {
+ var isFocused by remember { mutableStateOf(false) }
+
+ IconButton(
+ onClick = onClick,
+ modifier = Modifier
+ .size(48.dp)
+ .onFocusChanged { isFocused = it.isFocused },
+ colors = IconButtonDefaults.colors(
+ containerColor = Color.Transparent,
+ focusedContainerColor = Color.White.copy(alpha = 0.2f),
+ contentColor = Color.White,
+ focusedContentColor = Color.White
+ ),
+ shape = IconButtonDefaults.shape(shape = CircleShape)
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = contentDescription,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+}
+
+@Composable
+private fun ProgressBar(
+ currentPosition: Long,
+ duration: Long,
+ onSeekTo: (Long) -> Unit
+) {
+ val progress = if (duration > 0) {
+ (currentPosition.toFloat() / duration.toFloat()).coerceIn(0f, 1f)
+ } else 0f
+
+ val animatedProgress by animateFloatAsState(
+ targetValue = progress,
+ animationSpec = tween(100),
+ label = "progress"
+ )
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(6.dp)
+ .clip(RoundedCornerShape(3.dp))
+ .background(Color.White.copy(alpha = 0.3f))
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxHeight()
+ .fillMaxWidth(animatedProgress)
+ .clip(RoundedCornerShape(3.dp))
+ .background(NuvioColors.Primary)
+ )
+ }
+}
+
+@Composable
+private fun ErrorOverlay(
+ message: String,
+ onRetry: () -> Unit,
+ onBack: () -> Unit
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.9f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "Playback Error",
+ style = MaterialTheme.typography.headlineMedium,
+ color = Color.White
+ )
+
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyLarge,
+ color = Color.White.copy(alpha = 0.7f),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 32.dp)
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ DialogButton(
+ text = "Go Back",
+ onClick = onBack,
+ isPrimary = false
+ )
+
+ DialogButton(
+ text = "Retry",
+ onClick = onRetry,
+ isPrimary = true
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun TrackSelectionDialog(
+ title: String,
+ tracks: List,
+ selectedIndex: Int,
+ onTrackSelected: (Int) -> Unit,
+ onDismiss: () -> Unit
+) {
+ Dialog(onDismissRequest = onDismiss) {
+ Box(
+ modifier = Modifier
+ .width(400.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(NuvioColors.BackgroundElevated)
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = NuvioColors.TextPrimary,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ TvLazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.height(300.dp)
+ ) {
+ items(tracks) { track ->
+ TrackItem(
+ track = track,
+ isSelected = track.index == selectedIndex,
+ onClick = { onTrackSelected(track.index) }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SubtitleSelectionDialog(
+ tracks: List,
+ selectedIndex: Int,
+ onTrackSelected: (Int) -> Unit,
+ onDisableSubtitles: () -> Unit,
+ onDismiss: () -> Unit
+) {
+ Dialog(onDismissRequest = onDismiss) {
+ Box(
+ modifier = Modifier
+ .width(400.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(NuvioColors.BackgroundElevated)
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp)
+ ) {
+ Text(
+ text = "Subtitles",
+ style = MaterialTheme.typography.headlineSmall,
+ color = NuvioColors.TextPrimary,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ TvLazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.height(300.dp)
+ ) {
+ // Off option
+ item {
+ TrackItem(
+ track = TrackInfo(index = -1, name = "Off", language = null),
+ isSelected = selectedIndex == -1,
+ onClick = onDisableSubtitles
+ )
+ }
+
+ items(tracks) { track ->
+ TrackItem(
+ track = track,
+ isSelected = track.index == selectedIndex,
+ onClick = { onTrackSelected(track.index) }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SpeedSelectionDialog(
+ currentSpeed: Float,
+ onSpeedSelected: (Float) -> Unit,
+ onDismiss: () -> Unit
+) {
+ Dialog(onDismissRequest = onDismiss) {
+ Box(
+ modifier = Modifier
+ .width(300.dp)
+ .clip(RoundedCornerShape(16.dp))
+ .background(NuvioColors.BackgroundElevated)
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp)
+ ) {
+ Text(
+ text = "Playback Speed",
+ style = MaterialTheme.typography.headlineSmall,
+ color = NuvioColors.TextPrimary,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ TvLazyColumn(
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(PLAYBACK_SPEEDS) { speed ->
+ SpeedItem(
+ speed = speed,
+ isSelected = speed == currentSpeed,
+ onClick = { onSpeedSelected(speed) }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TrackItem(
+ track: TrackInfo,
+ isSelected: Boolean,
+ onClick: () -> Unit
+) {
+ var isFocused by remember { mutableStateOf(false) }
+
+ Card(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .onFocusChanged { isFocused = it.isFocused },
+ colors = CardDefaults.colors(
+ containerColor = if (isSelected) NuvioColors.Primary.copy(alpha = 0.2f) else NuvioColors.BackgroundCard,
+ focusedContainerColor = NuvioColors.Primary.copy(alpha = 0.4f)
+ ),
+ shape = CardDefaults.shape(shape = RoundedCornerShape(8.dp))
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column {
+ Text(
+ text = track.name,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (isSelected) NuvioColors.Primary else NuvioColors.TextPrimary
+ )
+ if (track.language != null) {
+ Text(
+ text = track.language.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = NuvioTheme.extendedColors.textSecondary
+ )
+ }
+ }
+
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = "Selected",
+ tint = NuvioColors.Primary,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun SpeedItem(
+ speed: Float,
+ isSelected: Boolean,
+ onClick: () -> Unit
+) {
+ var isFocused by remember { mutableStateOf(false) }
+
+ Card(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .onFocusChanged { isFocused = it.isFocused },
+ colors = CardDefaults.colors(
+ containerColor = if (isSelected) NuvioColors.Primary.copy(alpha = 0.2f) else NuvioColors.BackgroundCard,
+ focusedContainerColor = NuvioColors.Primary.copy(alpha = 0.4f)
+ ),
+ shape = CardDefaults.shape(shape = RoundedCornerShape(8.dp))
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = if (speed == 1f) "Normal" else "${speed}x",
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (isSelected) NuvioColors.Primary else NuvioColors.TextPrimary
+ )
+
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = "Selected",
+ tint = NuvioColors.Primary,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DialogButton(
+ text: String,
+ onClick: () -> Unit,
+ isPrimary: Boolean
+) {
+ var isFocused by remember { mutableStateOf(false) }
+
+ Card(
+ onClick = onClick,
+ modifier = Modifier.onFocusChanged { isFocused = it.isFocused },
+ colors = CardDefaults.colors(
+ containerColor = if (isPrimary) NuvioColors.Primary else NuvioColors.BackgroundCard,
+ focusedContainerColor = if (isPrimary) NuvioColors.Primary else NuvioColors.FocusBackground
+ ),
+ shape = CardDefaults.shape(shape = RoundedCornerShape(8.dp))
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge,
+ color = if (isPrimary) NuvioColors.OnPrimary else NuvioColors.TextPrimary,
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
+ )
+ }
+}
+
+private fun formatTime(millis: Long): String {
+ if (millis <= 0) return "0:00"
+
+ val hours = TimeUnit.MILLISECONDS.toHours(millis)
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60
+ val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
+
+ return if (hours > 0) {
+ String.format("%d:%02d:%02d", hours, minutes, seconds)
+ } else {
+ String.format("%d:%02d", minutes, seconds)
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt
new file mode 100644
index 00000000..8d20d30a
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerUiState.kt
@@ -0,0 +1,49 @@
+package com.nuvio.tv.ui.screens.player
+
+import androidx.media3.common.C
+import androidx.media3.common.TrackGroup
+
+data class PlayerUiState(
+ val isPlaying: Boolean = false,
+ val isBuffering: Boolean = true,
+ val currentPosition: Long = 0L,
+ val duration: Long = 0L,
+ val title: String = "",
+ val showControls: Boolean = true,
+ val playbackSpeed: Float = 1f,
+ val audioTracks: List = emptyList(),
+ val subtitleTracks: List = emptyList(),
+ val selectedAudioTrackIndex: Int = -1,
+ val selectedSubtitleTrackIndex: Int = -1,
+ val showAudioDialog: Boolean = false,
+ val showSubtitleDialog: Boolean = false,
+ val showSpeedDialog: Boolean = false,
+ val error: String? = null,
+ val pendingSeekPosition: Long? = null // For resuming from saved progress
+)
+
+data class TrackInfo(
+ val index: Int,
+ val name: String,
+ val language: String?,
+ val isSelected: Boolean = false
+)
+
+sealed class PlayerEvent {
+ data object OnPlayPause : PlayerEvent()
+ data object OnSeekForward : PlayerEvent()
+ data object OnSeekBackward : PlayerEvent()
+ data class OnSeekTo(val position: Long) : PlayerEvent()
+ data class OnSelectAudioTrack(val index: Int) : PlayerEvent()
+ data class OnSelectSubtitleTrack(val index: Int) : PlayerEvent()
+ data object OnDisableSubtitles : PlayerEvent()
+ data class OnSetPlaybackSpeed(val speed: Float) : PlayerEvent()
+ data object OnToggleControls : PlayerEvent()
+ data object OnShowAudioDialog : PlayerEvent()
+ data object OnShowSubtitleDialog : PlayerEvent()
+ data object OnShowSpeedDialog : PlayerEvent()
+ data object OnDismissDialog : PlayerEvent()
+ data object OnRetry : PlayerEvent()
+}
+
+val PLAYBACK_SPEEDS = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f)
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt
new file mode 100644
index 00000000..7f82abd7
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerViewModel.kt
@@ -0,0 +1,577 @@
+package com.nuvio.tv.ui.screens.player
+
+import android.content.Context
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import androidx.media3.common.C
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.TrackSelectionOverride
+import androidx.media3.common.Tracks
+import androidx.media3.exoplayer.DefaultRenderersFactory
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.exoplayer.hls.HlsMediaSource
+import androidx.media3.exoplayer.dash.DashMediaSource
+import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
+import androidx.media3.datasource.DefaultHttpDataSource
+import androidx.media3.common.MimeTypes
+import com.nuvio.tv.domain.model.WatchProgress
+import com.nuvio.tv.domain.repository.WatchProgressRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.net.URLDecoder
+import javax.inject.Inject
+
+@HiltViewModel
+class PlayerViewModel @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val watchProgressRepository: WatchProgressRepository,
+ savedStateHandle: SavedStateHandle
+) : ViewModel() {
+
+ private val streamUrl: String = savedStateHandle.get("streamUrl")?.let {
+ URLDecoder.decode(it, "UTF-8")
+ } ?: ""
+ private val title: String = savedStateHandle.get("title")?.let {
+ URLDecoder.decode(it, "UTF-8")
+ } ?: ""
+ private val headersJson: String? = savedStateHandle.get("headers")?.let {
+ if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
+ }
+
+ // Watch progress metadata
+ private val contentId: String? = savedStateHandle.get("contentId")?.let {
+ if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
+ }
+ private val contentType: String? = savedStateHandle.get("contentType")?.let {
+ if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
+ }
+ private val contentName: String? = savedStateHandle.get("contentName")?.let {
+ if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
+ }
+ private val poster: String? = savedStateHandle.get("poster")?.let {
+ if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
+ }
+ private val backdrop: String? = savedStateHandle.get("backdrop")?.let {
+ if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
+ }
+ private val logo: String? = savedStateHandle.get("logo")?.let {
+ if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
+ }
+ private val videoId: String? = savedStateHandle.get("videoId")?.let {
+ if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
+ }
+ private val season: Int? = savedStateHandle.get("season")?.toIntOrNull()
+ private val episode: Int? = savedStateHandle.get("episode")?.toIntOrNull()
+ private val episodeTitle: String? = savedStateHandle.get("episodeTitle")?.let {
+ if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
+ }
+
+ private val _uiState = MutableStateFlow(PlayerUiState(title = title))
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var _exoPlayer: ExoPlayer? = null
+ val exoPlayer: ExoPlayer?
+ get() = _exoPlayer
+
+ private var progressJob: Job? = null
+ private var hideControlsJob: Job? = null
+ private var watchProgressSaveJob: Job? = null
+
+ // Track last saved position to avoid redundant saves
+ private var lastSavedPosition: Long = 0L
+ private val saveThresholdMs = 5000L // Save every 5 seconds of playback change
+
+ init {
+ initializePlayer()
+ loadSavedProgress()
+ }
+
+ private fun loadSavedProgress() {
+ if (contentId == null) return
+
+ viewModelScope.launch {
+ val progress = if (season != null && episode != null) {
+ watchProgressRepository.getEpisodeProgress(contentId, season, episode).first()
+ } else {
+ watchProgressRepository.getProgress(contentId).first()
+ }
+
+ progress?.let { saved ->
+ // Only seek if we have a meaningful position (more than 2% but less than 90%)
+ if (saved.isInProgress()) {
+ _exoPlayer?.let { player ->
+ // Wait for player to be ready before seeking
+ if (player.playbackState == Player.STATE_READY) {
+ player.seekTo(saved.position)
+ } else {
+ // Set a flag to seek when ready
+ _uiState.update { it.copy(pendingSeekPosition = saved.position) }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun initializePlayer() {
+ if (streamUrl.isEmpty()) {
+ _uiState.update { it.copy(error = "No stream URL provided") }
+ return
+ }
+
+ try {
+ val renderersFactory = DefaultRenderersFactory(context)
+ .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
+
+ _exoPlayer = ExoPlayer.Builder(context)
+ .setRenderersFactory(renderersFactory)
+ .build().apply {
+ // Create data source factory with optional headers
+ val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
+ setDefaultRequestProperties(parseHeaders())
+ setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
+ }
+
+ // Detect stream type from URL
+ val isHls = streamUrl.contains(".m3u8", ignoreCase = true) ||
+ streamUrl.contains("/playlist", ignoreCase = true) ||
+ streamUrl.contains("/hls", ignoreCase = true) ||
+ streamUrl.contains("m3u8", ignoreCase = true)
+
+ val isDash = streamUrl.contains(".mpd", ignoreCase = true) ||
+ streamUrl.contains("/dash", ignoreCase = true)
+
+ // Create media item with MIME type hint for better detection
+ val mediaItemBuilder = MediaItem.Builder()
+ .setUri(streamUrl)
+
+ when {
+ isHls -> mediaItemBuilder.setMimeType(MimeTypes.APPLICATION_M3U8)
+ isDash -> mediaItemBuilder.setMimeType(MimeTypes.APPLICATION_MPD)
+ }
+
+ val mediaItem = mediaItemBuilder.build()
+
+ // Create media source based on detected type
+ val mediaSource = when {
+ isHls -> {
+ HlsMediaSource.Factory(dataSourceFactory)
+ .setAllowChunklessPreparation(true)
+ .createMediaSource(mediaItem)
+ }
+ isDash -> {
+ DashMediaSource.Factory(dataSourceFactory)
+ .createMediaSource(mediaItem)
+ }
+ else -> {
+ // Use default factory which will try to auto-detect
+ DefaultMediaSourceFactory(dataSourceFactory)
+ .createMediaSource(mediaItem)
+ }
+ }
+
+ setMediaSource(mediaSource)
+
+ playWhenReady = true
+ prepare()
+
+ addListener(object : Player.Listener {
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ val isBuffering = playbackState == Player.STATE_BUFFERING
+ _uiState.update {
+ it.copy(
+ isBuffering = isBuffering,
+ duration = duration.coerceAtLeast(0L)
+ )
+ }
+
+ // Handle pending seek position when player is ready
+ if (playbackState == Player.STATE_READY) {
+ _uiState.value.pendingSeekPosition?.let { position ->
+ seekTo(position)
+ _uiState.update { it.copy(pendingSeekPosition = null) }
+ }
+ }
+
+ // Save progress when playback ends
+ if (playbackState == Player.STATE_ENDED) {
+ saveWatchProgress()
+ }
+ }
+
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ _uiState.update { it.copy(isPlaying = isPlaying) }
+ if (isPlaying) {
+ startProgressUpdates()
+ startWatchProgressSaving()
+ scheduleHideControls()
+ } else {
+ stopProgressUpdates()
+ stopWatchProgressSaving()
+ // Save progress when paused
+ saveWatchProgress()
+ }
+ }
+
+ override fun onTracksChanged(tracks: Tracks) {
+ updateAvailableTracks(tracks)
+ }
+
+ override fun onPlayerError(error: PlaybackException) {
+ _uiState.update {
+ it.copy(error = error.message ?: "Playback error occurred")
+ }
+ }
+ })
+ }
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = e.message ?: "Failed to initialize player") }
+ }
+ }
+
+ private fun parseHeaders(): Map {
+ if (headersJson.isNullOrEmpty()) return emptyMap()
+
+ return try {
+ // Simple parsing for key=value&key2=value2 format
+ headersJson.split("&").associate { pair ->
+ val parts = pair.split("=", limit = 2)
+ if (parts.size == 2) {
+ URLDecoder.decode(parts[0], "UTF-8") to URLDecoder.decode(parts[1], "UTF-8")
+ } else {
+ "" to ""
+ }
+ }.filterKeys { it.isNotEmpty() }
+ } catch (e: Exception) {
+ emptyMap()
+ }
+ }
+
+ private fun updateAvailableTracks(tracks: Tracks) {
+ val audioTracks = mutableListOf()
+ val subtitleTracks = mutableListOf()
+ var selectedAudioIndex = -1
+ var selectedSubtitleIndex = -1
+
+ tracks.groups.forEachIndexed { groupIndex, trackGroup ->
+ val trackType = trackGroup.type
+
+ when (trackType) {
+ C.TRACK_TYPE_AUDIO -> {
+ for (i in 0 until trackGroup.length) {
+ val format = trackGroup.getTrackFormat(i)
+ val isSelected = trackGroup.isTrackSelected(i)
+ if (isSelected) selectedAudioIndex = audioTracks.size
+
+ audioTracks.add(
+ TrackInfo(
+ index = audioTracks.size,
+ name = format.label ?: "Audio ${audioTracks.size + 1}",
+ language = format.language,
+ isSelected = isSelected
+ )
+ )
+ }
+ }
+ C.TRACK_TYPE_TEXT -> {
+ for (i in 0 until trackGroup.length) {
+ val format = trackGroup.getTrackFormat(i)
+ val isSelected = trackGroup.isTrackSelected(i)
+ if (isSelected) selectedSubtitleIndex = subtitleTracks.size
+
+ subtitleTracks.add(
+ TrackInfo(
+ index = subtitleTracks.size,
+ name = format.label ?: format.language ?: "Subtitle ${subtitleTracks.size + 1}",
+ language = format.language,
+ isSelected = isSelected
+ )
+ )
+ }
+ }
+ }
+ }
+
+ _uiState.update {
+ it.copy(
+ audioTracks = audioTracks,
+ subtitleTracks = subtitleTracks,
+ selectedAudioTrackIndex = selectedAudioIndex,
+ selectedSubtitleTrackIndex = selectedSubtitleIndex
+ )
+ }
+ }
+
+ private fun startProgressUpdates() {
+ progressJob?.cancel()
+ progressJob = viewModelScope.launch {
+ while (isActive) {
+ _exoPlayer?.let { player ->
+ _uiState.update {
+ it.copy(
+ currentPosition = player.currentPosition.coerceAtLeast(0L),
+ duration = player.duration.coerceAtLeast(0L)
+ )
+ }
+ }
+ delay(500)
+ }
+ }
+ }
+
+ private fun stopProgressUpdates() {
+ progressJob?.cancel()
+ progressJob = null
+ }
+
+ private fun startWatchProgressSaving() {
+ watchProgressSaveJob?.cancel()
+ watchProgressSaveJob = viewModelScope.launch {
+ while (isActive) {
+ delay(10000) // Save every 10 seconds
+ saveWatchProgressIfNeeded()
+ }
+ }
+ }
+
+ private fun stopWatchProgressSaving() {
+ watchProgressSaveJob?.cancel()
+ watchProgressSaveJob = null
+ }
+
+ private fun saveWatchProgressIfNeeded() {
+ val currentPosition = _exoPlayer?.currentPosition ?: return
+ val duration = _exoPlayer?.duration ?: return
+
+ // Only save if position has changed significantly
+ if (kotlin.math.abs(currentPosition - lastSavedPosition) >= saveThresholdMs) {
+ lastSavedPosition = currentPosition
+ saveWatchProgressInternal(currentPosition, duration)
+ }
+ }
+
+ private fun saveWatchProgress() {
+ val currentPosition = _exoPlayer?.currentPosition ?: return
+ val duration = _exoPlayer?.duration ?: return
+ saveWatchProgressInternal(currentPosition, duration)
+ }
+
+ private fun saveWatchProgressInternal(position: Long, duration: Long) {
+ // Don't save if we don't have content metadata
+ if (contentId.isNullOrEmpty() || contentType.isNullOrEmpty()) return
+ // Don't save if duration is invalid
+ if (duration <= 0) return
+ // Don't save if position is too early (less than 1 second)
+ if (position < 1000) return
+
+ val progress = WatchProgress(
+ contentId = contentId,
+ contentType = contentType,
+ name = contentName ?: title,
+ poster = poster,
+ backdrop = backdrop,
+ logo = logo,
+ videoId = videoId ?: contentId,
+ season = season,
+ episode = episode,
+ episodeTitle = episodeTitle,
+ position = position,
+ duration = duration,
+ lastWatched = System.currentTimeMillis()
+ )
+
+ viewModelScope.launch {
+ watchProgressRepository.saveProgress(progress)
+ }
+ }
+
+ private fun scheduleHideControls() {
+ hideControlsJob?.cancel()
+ hideControlsJob = viewModelScope.launch {
+ delay(3000)
+ if (_uiState.value.isPlaying && !_uiState.value.showAudioDialog &&
+ !_uiState.value.showSubtitleDialog && !_uiState.value.showSpeedDialog) {
+ _uiState.update { it.copy(showControls = false) }
+ }
+ }
+ }
+
+ fun hideControls() {
+ hideControlsJob?.cancel()
+ _uiState.update { it.copy(showControls = false) }
+ }
+
+ fun onEvent(event: PlayerEvent) {
+ when (event) {
+ PlayerEvent.OnPlayPause -> {
+ _exoPlayer?.let { player ->
+ if (player.isPlaying) {
+ player.pause()
+ } else {
+ player.play()
+ }
+ }
+ showControlsTemporarily()
+ }
+ PlayerEvent.OnSeekForward -> {
+ _exoPlayer?.let { player ->
+ player.seekTo((player.currentPosition + 10000).coerceAtMost(player.duration))
+ }
+ if (_uiState.value.showControls) {
+ scheduleHideControls()
+ }
+ }
+ PlayerEvent.OnSeekBackward -> {
+ _exoPlayer?.let { player ->
+ player.seekTo((player.currentPosition - 10000).coerceAtLeast(0))
+ }
+ if (_uiState.value.showControls) {
+ scheduleHideControls()
+ }
+ }
+ is PlayerEvent.OnSeekTo -> {
+ _exoPlayer?.seekTo(event.position)
+ if (_uiState.value.showControls) {
+ scheduleHideControls()
+ }
+ }
+ is PlayerEvent.OnSelectAudioTrack -> {
+ selectAudioTrack(event.index)
+ _uiState.update { it.copy(showAudioDialog = false) }
+ }
+ is PlayerEvent.OnSelectSubtitleTrack -> {
+ selectSubtitleTrack(event.index)
+ _uiState.update { it.copy(showSubtitleDialog = false) }
+ }
+ PlayerEvent.OnDisableSubtitles -> {
+ disableSubtitles()
+ _uiState.update { it.copy(showSubtitleDialog = false) }
+ }
+ is PlayerEvent.OnSetPlaybackSpeed -> {
+ _exoPlayer?.setPlaybackSpeed(event.speed)
+ _uiState.update {
+ it.copy(playbackSpeed = event.speed, showSpeedDialog = false)
+ }
+ }
+ PlayerEvent.OnToggleControls -> {
+ _uiState.update { it.copy(showControls = !it.showControls) }
+ if (_uiState.value.showControls) {
+ scheduleHideControls()
+ }
+ }
+ PlayerEvent.OnShowAudioDialog -> {
+ _uiState.update { it.copy(showAudioDialog = true, showControls = true) }
+ }
+ PlayerEvent.OnShowSubtitleDialog -> {
+ _uiState.update { it.copy(showSubtitleDialog = true, showControls = true) }
+ }
+ PlayerEvent.OnShowSpeedDialog -> {
+ _uiState.update { it.copy(showSpeedDialog = true, showControls = true) }
+ }
+ PlayerEvent.OnDismissDialog -> {
+ _uiState.update {
+ it.copy(
+ showAudioDialog = false,
+ showSubtitleDialog = false,
+ showSpeedDialog = false
+ )
+ }
+ scheduleHideControls()
+ }
+ PlayerEvent.OnRetry -> {
+ _uiState.update { it.copy(error = null) }
+ releasePlayer()
+ initializePlayer()
+ }
+ }
+ }
+
+ private fun showControlsTemporarily() {
+ _uiState.update { it.copy(showControls = true) }
+ scheduleHideControls()
+ }
+
+ private fun selectAudioTrack(trackIndex: Int) {
+ _exoPlayer?.let { player ->
+ val tracks = player.currentTracks
+ var currentAudioIndex = 0
+
+ tracks.groups.forEach { trackGroup ->
+ if (trackGroup.type == C.TRACK_TYPE_AUDIO) {
+ for (i in 0 until trackGroup.length) {
+ if (currentAudioIndex == trackIndex) {
+ val override = TrackSelectionOverride(trackGroup.mediaTrackGroup, i)
+ player.trackSelectionParameters = player.trackSelectionParameters
+ .buildUpon()
+ .setOverrideForType(override)
+ .build()
+ return
+ }
+ currentAudioIndex++
+ }
+ }
+ }
+ }
+ }
+
+ private fun selectSubtitleTrack(trackIndex: Int) {
+ _exoPlayer?.let { player ->
+ val tracks = player.currentTracks
+ var currentSubIndex = 0
+
+ tracks.groups.forEach { trackGroup ->
+ if (trackGroup.type == C.TRACK_TYPE_TEXT) {
+ for (i in 0 until trackGroup.length) {
+ if (currentSubIndex == trackIndex) {
+ val override = TrackSelectionOverride(trackGroup.mediaTrackGroup, i)
+ player.trackSelectionParameters = player.trackSelectionParameters
+ .buildUpon()
+ .setOverrideForType(override)
+ .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
+ .build()
+ return
+ }
+ currentSubIndex++
+ }
+ }
+ }
+ }
+ }
+
+ private fun disableSubtitles() {
+ _exoPlayer?.let { player ->
+ player.trackSelectionParameters = player.trackSelectionParameters
+ .buildUpon()
+ .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
+ .build()
+ }
+ }
+
+ private fun releasePlayer() {
+ // Save progress before releasing
+ saveWatchProgress()
+
+ progressJob?.cancel()
+ hideControlsJob?.cancel()
+ watchProgressSaveJob?.cancel()
+ _exoPlayer?.release()
+ _exoPlayer = null
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ releasePlayer()
+ }
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginScreen.kt
new file mode 100644
index 00000000..4f2ff314
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginScreen.kt
@@ -0,0 +1,768 @@
+@file:OptIn(ExperimentalTvMaterial3Api::class)
+
+package com.nuvio.tv.ui.screens.plugin
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Refresh
+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.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.tv.foundation.lazy.list.TvLazyColumn
+import androidx.tv.foundation.lazy.list.items
+import androidx.tv.material3.Button
+import androidx.tv.material3.ButtonDefaults
+import androidx.tv.material3.ClickableSurfaceDefaults
+import androidx.tv.material3.ExperimentalTvMaterial3Api
+import androidx.tv.material3.Icon
+import androidx.tv.material3.MaterialTheme
+import androidx.tv.material3.Surface
+import androidx.tv.material3.Switch
+import androidx.tv.material3.SwitchDefaults
+import androidx.tv.material3.Text
+import com.nuvio.tv.domain.model.LocalScraperResult
+import com.nuvio.tv.domain.model.PluginRepository
+import com.nuvio.tv.domain.model.ScraperInfo
+import com.nuvio.tv.ui.components.LoadingIndicator
+import com.nuvio.tv.ui.theme.NuvioColors
+import kotlinx.coroutines.delay
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+@Composable
+fun PluginScreen(
+ viewModel: PluginViewModel = hiltViewModel(),
+ onBackPress: () -> Unit
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ var showAddDialog by remember { mutableStateOf(false) }
+ var repoUrl by remember { mutableStateOf("") }
+
+ BackHandler {
+ if (showAddDialog) {
+ showAddDialog = false
+ } else {
+ onBackPress()
+ }
+ }
+
+ // Clear messages after delay
+ LaunchedEffect(uiState.successMessage) {
+ if (uiState.successMessage != null) {
+ delay(3000)
+ viewModel.onEvent(PluginUiEvent.ClearSuccess)
+ }
+ }
+
+ LaunchedEffect(uiState.errorMessage) {
+ if (uiState.errorMessage != null) {
+ delay(5000)
+ viewModel.onEvent(PluginUiEvent.ClearError)
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(NuvioColors.Background)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 48.dp, vertical = 24.dp)
+ ) {
+ // Header
+ PluginHeader(
+ pluginsEnabled = uiState.pluginsEnabled,
+ onPluginsEnabledChange = { viewModel.onEvent(PluginUiEvent.SetPluginsEnabled(it)) },
+ onAddRepository = { showAddDialog = true }
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Content
+ TvLazyColumn(
+ contentPadding = PaddingValues(bottom = 32.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ modifier = Modifier.weight(1f)
+ ) {
+ // Repositories section
+ item {
+ Text(
+ text = "Repositories (${uiState.repositories.size})",
+ style = MaterialTheme.typography.titleLarge,
+ color = NuvioColors.TextPrimary
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ if (uiState.repositories.isEmpty()) {
+ item {
+ EmptyState(
+ message = "No repositories added yet.\nAdd a repository to get started.",
+ modifier = Modifier.padding(vertical = 24.dp)
+ )
+ }
+ }
+
+ items(uiState.repositories, key = { it.id }) { repo ->
+ RepositoryCard(
+ repository = repo,
+ onRefresh = { viewModel.onEvent(PluginUiEvent.RefreshRepository(repo.id)) },
+ onRemove = { viewModel.onEvent(PluginUiEvent.RemoveRepository(repo.id)) },
+ isLoading = uiState.isLoading
+ )
+ }
+
+ // Scrapers section
+ if (uiState.scrapers.isNotEmpty()) {
+ item {
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = "Providers (${uiState.scrapers.size})",
+ style = MaterialTheme.typography.titleLarge,
+ color = NuvioColors.TextPrimary
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ items(uiState.scrapers, key = { it.id }) { scraper ->
+ ScraperCard(
+ scraper = scraper,
+ onToggle = { enabled ->
+ viewModel.onEvent(PluginUiEvent.ToggleScraper(scraper.id, enabled))
+ },
+ onTest = { viewModel.onEvent(PluginUiEvent.TestScraper(scraper.id)) },
+ isTesting = uiState.isTesting && uiState.testScraperId == scraper.id,
+ testResults = if (uiState.testScraperId == scraper.id) uiState.testResults else null
+ )
+ }
+ }
+ }
+ }
+
+ // Add Repository Dialog
+ AnimatedVisibility(
+ visible = showAddDialog,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ AddRepositoryDialog(
+ url = repoUrl,
+ onUrlChange = { repoUrl = it },
+ onConfirm = {
+ viewModel.onEvent(PluginUiEvent.AddRepository(repoUrl))
+ repoUrl = ""
+ showAddDialog = false
+ },
+ onDismiss = { showAddDialog = false },
+ isLoading = uiState.isAddingRepo
+ )
+ }
+
+ // Success/Error Messages
+ MessageOverlay(
+ successMessage = uiState.successMessage,
+ errorMessage = uiState.errorMessage
+ )
+ }
+}
+
+@Composable
+private fun PluginHeader(
+ pluginsEnabled: Boolean,
+ onPluginsEnabledChange: (Boolean) -> Unit,
+ onAddRepository: () -> Unit
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column {
+ Text(
+ text = "Plugins",
+ style = MaterialTheme.typography.headlineLarge,
+ color = NuvioColors.TextPrimary
+ )
+ Text(
+ text = "Manage local scrapers and providers",
+ style = MaterialTheme.typography.bodyMedium,
+ color = NuvioColors.TextSecondary
+ )
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Global enable toggle
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = if (pluginsEnabled) "Enabled" else "Disabled",
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (pluginsEnabled) NuvioColors.Primary else NuvioColors.TextSecondary
+ )
+ Switch(
+ checked = pluginsEnabled,
+ onCheckedChange = onPluginsEnabledChange,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = NuvioColors.Primary,
+ checkedTrackColor = NuvioColors.Primary.copy(alpha = 0.3f)
+ )
+ )
+ }
+
+ // Add button
+ Button(
+ onClick = onAddRepository,
+ colors = ButtonDefaults.colors(
+ containerColor = NuvioColors.Primary,
+ contentColor = Color.White
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = "Add",
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Add Repository")
+ }
+ }
+ }
+}
+
+@Composable
+private fun RepositoryCard(
+ repository: PluginRepository,
+ onRefresh: () -> Unit,
+ onRemove: () -> Unit,
+ isLoading: Boolean
+) {
+ var isFocused by remember { mutableStateOf(false) }
+
+ Surface(
+ onClick = { },
+ modifier = Modifier
+ .fillMaxWidth()
+ .onFocusChanged { isFocused = it.isFocused },
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = if (isFocused) NuvioColors.FocusBackground else NuvioColors.BackgroundCard,
+ focusedContainerColor = NuvioColors.FocusBackground
+ ),
+ shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp))
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = repository.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = NuvioColors.TextPrimary
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "${repository.scraperCount} providers",
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioColors.TextSecondary
+ )
+ Text(
+ text = "Updated: ${formatDate(repository.lastUpdated)}",
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioColors.TextSecondary
+ )
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(
+ onClick = onRefresh,
+ enabled = !isLoading,
+ colors = ButtonDefaults.colors(
+ containerColor = NuvioColors.Surface
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.Refresh,
+ contentDescription = "Refresh",
+ tint = NuvioColors.TextSecondary
+ )
+ }
+
+ Button(
+ onClick = onRemove,
+ enabled = !isLoading,
+ colors = ButtonDefaults.colors(
+ containerColor = NuvioColors.Surface
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "Remove",
+ tint = Color(0xFFE57373)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ScraperCard(
+ scraper: ScraperInfo,
+ onToggle: (Boolean) -> Unit,
+ onTest: () -> Unit,
+ isTesting: Boolean,
+ testResults: List?
+) {
+ var showResults by remember { mutableStateOf(false) }
+
+ LaunchedEffect(testResults) {
+ showResults = testResults != null
+ }
+
+ // Use Box instead of focusable Surface to allow child focus
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = NuvioColors.BackgroundCard,
+ shape = RoundedCornerShape(12.dp)
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = scraper.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = NuvioColors.TextPrimary
+ )
+
+ // Type badges
+ scraper.supportedTypes.forEach { type ->
+ TypeBadge(type = type)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = "Version ${scraper.version}",
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioColors.TextSecondary
+ )
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Test button
+ Button(
+ onClick = onTest,
+ enabled = !isTesting && scraper.enabled,
+ colors = ButtonDefaults.colors(
+ containerColor = NuvioColors.Surface,
+ contentColor = NuvioColors.TextPrimary
+ )
+ ) {
+ if (isTesting) {
+ LoadingIndicator(modifier = Modifier.size(16.dp))
+ } else {
+ Icon(
+ imageVector = Icons.Default.PlayArrow,
+ contentDescription = "Test",
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Test")
+ }
+
+ // Enable toggle
+ Switch(
+ checked = scraper.enabled,
+ onCheckedChange = onToggle,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = NuvioColors.Primary,
+ checkedTrackColor = NuvioColors.Primary.copy(alpha = 0.3f)
+ )
+ )
+ }
+ }
+
+ // Test results
+ AnimatedVisibility(visible = showResults && testResults != null) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp)
+ ) {
+ Text(
+ text = "Test Results (${testResults?.size ?: 0} streams)",
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioColors.TextSecondary
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+
+ testResults?.take(3)?.forEach { result ->
+ TestResultItem(result = result)
+ Spacer(modifier = Modifier.height(4.dp))
+ }
+
+ if ((testResults?.size ?: 0) > 3) {
+ Text(
+ text = "... and ${testResults!!.size - 3} more",
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioColors.TextSecondary
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TypeBadge(type: String) {
+ val color = when (type.lowercase()) {
+ "movie" -> Color(0xFF4CAF50)
+ "series", "show", "tv" -> Color(0xFF2196F3)
+ else -> NuvioColors.TextSecondary
+ }
+
+ Box(
+ modifier = Modifier
+ .background(
+ color = color.copy(alpha = 0.2f),
+ shape = RoundedCornerShape(4.dp)
+ )
+ .padding(horizontal = 6.dp, vertical = 2.dp)
+ ) {
+ Text(
+ text = type.uppercase(),
+ style = MaterialTheme.typography.labelSmall,
+ color = color
+ )
+ }
+}
+
+@Composable
+private fun TestResultItem(result: LocalScraperResult) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = NuvioColors.Surface,
+ shape = RoundedCornerShape(6.dp)
+ )
+ .padding(8.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = result.title,
+ style = MaterialTheme.typography.bodySmall,
+ color = NuvioColors.TextPrimary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ result.quality?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.labelSmall,
+ color = NuvioColors.Primary
+ )
+ }
+ }
+
+ Icon(
+ imageVector = Icons.Default.Check,
+ contentDescription = null,
+ tint = Color(0xFF4CAF50),
+ modifier = Modifier.size(16.dp)
+ )
+ }
+ }
+}
+
+@Composable
+private fun AddRepositoryDialog(
+ url: String,
+ onUrlChange: (String) -> Unit,
+ onConfirm: () -> Unit,
+ onDismiss: () -> Unit,
+ isLoading: Boolean
+) {
+ val focusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.8f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Surface(
+ onClick = { },
+ modifier = Modifier
+ .width(500.dp)
+ .focusRequester(focusRequester),
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = NuvioColors.BackgroundCard
+ ),
+ shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(16.dp))
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "Add Repository",
+ style = MaterialTheme.typography.headlineSmall,
+ color = NuvioColors.TextPrimary
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = "Enter the URL to a manifest.json file",
+ style = MaterialTheme.typography.bodyMedium,
+ color = NuvioColors.TextSecondary
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Custom text field using BasicTextField
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ color = NuvioColors.Surface,
+ shape = RoundedCornerShape(8.dp)
+ )
+ .border(
+ width = 1.dp,
+ color = NuvioColors.Border,
+ shape = RoundedCornerShape(8.dp)
+ )
+ .padding(16.dp)
+ ) {
+ if (url.isEmpty()) {
+ Text(
+ text = "https://example.com/manifest.json",
+ style = TextStyle(
+ color = NuvioColors.TextTertiary,
+ fontSize = 14.sp
+ )
+ )
+ }
+ BasicTextField(
+ value = url,
+ onValueChange = onUrlChange,
+ textStyle = TextStyle(
+ color = NuvioColors.TextPrimary,
+ fontSize = 14.sp
+ ),
+ cursorBrush = SolidColor(NuvioColors.Primary),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Done
+ ),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Button(
+ onClick = onDismiss,
+ enabled = !isLoading,
+ colors = ButtonDefaults.colors(
+ containerColor = NuvioColors.Surface,
+ contentColor = NuvioColors.TextPrimary
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Cancel")
+ }
+
+ Button(
+ onClick = onConfirm,
+ enabled = !isLoading && url.isNotBlank(),
+ colors = ButtonDefaults.colors(
+ containerColor = NuvioColors.Primary,
+ contentColor = Color.White
+ )
+ ) {
+ if (isLoading) {
+ LoadingIndicator(modifier = Modifier.size(18.dp))
+ } else {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Add")
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmptyState(
+ message: String,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier.fillMaxWidth(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyLarge,
+ color = NuvioColors.TextSecondary,
+ textAlign = TextAlign.Center
+ )
+ }
+}
+
+@Composable
+private fun MessageOverlay(
+ successMessage: String?,
+ errorMessage: String?
+) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ contentAlignment = Alignment.BottomCenter
+ ) {
+ AnimatedVisibility(
+ visible = successMessage != null || errorMessage != null,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ val isSuccess = successMessage != null
+ val message = successMessage ?: errorMessage ?: ""
+
+ Surface(
+ onClick = { },
+ colors = ClickableSurfaceDefaults.colors(
+ containerColor = if (isSuccess)
+ Color(0xFF2E7D32).copy(alpha = 0.9f)
+ else
+ Color(0xFFC62828).copy(alpha = 0.9f)
+ ),
+ shape = ClickableSurfaceDefaults.shape(RoundedCornerShape(12.dp))
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Icon(
+ imageVector = if (isSuccess) Icons.Default.Check else Icons.Default.Close,
+ contentDescription = null,
+ tint = Color.White
+ )
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = Color.White
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun formatDate(timestamp: Long): String {
+ return SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(Date(timestamp))
+}
diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginUiState.kt
new file mode 100644
index 00000000..a3114881
--- /dev/null
+++ b/app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginUiState.kt
@@ -0,0 +1,30 @@
+package com.nuvio.tv.ui.screens.plugin
+
+import com.nuvio.tv.domain.model.LocalScraperResult
+import com.nuvio.tv.domain.model.PluginRepository
+import com.nuvio.tv.domain.model.ScraperInfo
+
+data class PluginUiState(
+ val pluginsEnabled: Boolean = true,
+ val repositories: List = emptyList(),
+ val scrapers: List