Initial commit: NuvioTV as clean branch

This commit is contained in:
tapframe 2026-02-01 19:56:04 +05:30
commit d9c0381439
132 changed files with 13601 additions and 0 deletions

15
.gitignore vendored Normal file
View file

@ -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

3
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

1
.idea/.name Normal file
View file

@ -0,0 +1 @@
My Application

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
.idea/compiler.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-29T06:35:02.162605Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/nayifnoushad/.android/avd/Television_1080p.avd" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

13
.idea/deviceManager.xml Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

19
.idea/gradle.xml Normal file
View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View file

@ -0,0 +1,50 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
</profile>
</component>

10
.idea/migrations.xml Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

9
.idea/misc.xml Normal file
View file

@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

10
.idea/vcs.xml Normal file
View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/nuvio-providers" vcs="Git" />
<mapping directory="$PROJECT_DIR$/nuvio-providers/aesdecryptor" vcs="Git" />
<mapping directory="$PROJECT_DIR$/third_party/media" vcs="Git" />
<mapping directory="$PROJECT_DIR$/third_party/media/libraries/decoder_ffmpeg/src/main/jni/ffmpeg" vcs="Git" />
</component>
</project>

View file

@ -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

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

115
app/build.gradle.kts Normal file
View file

@ -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)
}

Binary file not shown.

21
app/proguard-rules.pro vendored Normal file
View file

@ -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

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<application
android:name=".NuvioApplication"
android:allowBackup="true"
android:banner="@mipmap/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:label="Nuvio"
android:supportsRtl="true"
android:theme="@style/Theme.MyApplication">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -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)
}
}
}
}
}
}
}

View file

@ -0,0 +1,7 @@
package com.nuvio.tv
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class NuvioApplication : Application()

View file

@ -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)
}

View file

@ -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
}

View file

@ -0,0 +1,7 @@
package com.nuvio.tv.core.network
sealed class NetworkResult<out T> {
data class Success<T>(val data: T) : NetworkResult<T>()
data class Error(val message: String, val code: Int? = null) : NetworkResult<Nothing>()
data object Loading : NetworkResult<Nothing>()
}

View file

@ -0,0 +1,18 @@
package com.nuvio.tv.core.network
import retrofit2.Response
suspend fun <T> safeApiCall(apiCall: suspend () -> Response<T>): NetworkResult<T> {
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")
}
}

View file

@ -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<String, kotlinx.coroutines.Deferred<List<LocalScraperResult>>>()
// Semaphore to limit concurrent scrapers
private val scraperSemaphore = Semaphore(MAX_CONCURRENT_SCRAPERS)
// Flow of all repositories
val repositories: Flow<List<PluginRepository>> = dataStore.repositories
// Flow of all scrapers
val scrapers: Flow<List<ScraperInfo>> = dataStore.scrapers
// Flow of plugins enabled state
val pluginsEnabled: Flow<Boolean> = dataStore.pluginsEnabled
// Combined flow of enabled scrapers
val enabledScrapers: Flow<List<ScraperInfo>> = 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<PluginRepository> = 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<Unit> = 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<LocalScraperResult> = 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<Pair<String, List<LocalScraperResult>>> = 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<LocalScraperResult> {
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<LocalScraperResult> {
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<List<LocalScraperResult>> {
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<ScraperManifestInfo>
) = 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)
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<com.nuvio.tv.data.remote.api.TmdbImage> { 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<Int>
): Map<Pair<Int, Int>, TmdbEpisodeEnrichment> = withContext(Dispatchers.IO) {
val numericId = tmdbId.toIntOrNull() ?: return@withContext emptyMap()
val result = mutableMapOf<Pair<Int, Int>, 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<String>,
val backdrop: String?,
val logo: String?,
val poster: String?,
val castMembers: List<MetaCastMember>,
val releaseInfo: String?,
val rating: Double?,
val runtimeMinutes: Int?,
val director: List<String>,
val writer: List<String>,
val productionCompanies: List<MetaCompany>,
val networks: List<MetaCompany>,
val countries: List<String>?,
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
)
}

View file

@ -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<String, Int>()
// Cache: TMDB ID -> IMDB ID
private val tmdbToImdbCache = ConcurrentHashMap<Int, String>()
// 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
}
}

View file

@ -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<Preferences> 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<Set<String>> = 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<String> = setOf(
"https://v3-cinemeta.strem.io"
)
}

View file

@ -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<Preferences> 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<List<SavedLibraryItem>> = 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<Boolean> {
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()
}
}
}

View file

@ -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<Preferences> 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<List<PluginRepository>> = dataStore.data.map { prefs ->
prefs[repositoriesKey]?.let { json ->
try {
moshi.adapter<List<PluginRepository>>(repoListType).fromJson(json) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
} ?: emptyList()
}
suspend fun saveRepositories(repos: List<PluginRepository>) {
val json = moshi.adapter<List<PluginRepository>>(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<List<ScraperInfo>> = dataStore.data.map { prefs ->
prefs[scrapersKey]?.let { json ->
try {
moshi.adapter<List<ScraperInfo>>(scraperListType).fromJson(json) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
} ?: emptyList()
}
suspend fun saveScrapers(scrapers: List<ScraperInfo>) {
val json = moshi.adapter<List<ScraperInfo>>(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<Boolean> = 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<String, Any> {
val prefs = dataStore.data.first()
val allSettings = prefs[scraperSettingsKey]?.let { json ->
try {
@Suppress("UNCHECKED_CAST")
moshi.adapter<Map<String, Map<String, Any>>>(settingsMapType).fromJson(json) ?: emptyMap()
} catch (e: Exception) {
emptyMap()
}
} ?: emptyMap()
@Suppress("UNCHECKED_CAST")
return allSettings[scraperId] as? Map<String, Any> ?: emptyMap()
}
suspend fun setScraperSettings(scraperId: String, settings: Map<String, Any>) {
val prefs = dataStore.data.first()
val allSettings = prefs[scraperSettingsKey]?.let { json ->
try {
@Suppress("UNCHECKED_CAST")
moshi.adapter<Map<String, Map<String, Any>>>(settingsMapType).fromJson(json)?.toMutableMap()
?: mutableMapOf()
} catch (e: Exception) {
mutableMapOf()
}
} ?: mutableMapOf()
allSettings[scraperId] = settings
val json = moshi.adapter<Map<String, Map<String, Any>>>(settingsMapType).toJson(allSettings)
dataStore.edit { p ->
p[scraperSettingsKey] = json
}
}
}

View file

@ -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<Preferences> 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<TmdbSettings> = 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 }
}
}

View file

@ -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<Preferences> 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<List<WatchProgress>> = 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<List<WatchProgress>> = allProgress.map { list ->
list.filter { it.isInProgress() }
}
/**
* Get watch progress for a specific content item
*/
fun getProgress(contentId: String): Flow<WatchProgress?> {
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<WatchProgress?> {
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<Map<Pair<Int, Int>, 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<String, WatchProgress> {
return try {
val type = object : TypeToken<Map<String, WatchProgress>>() {}.type
gson.fromJson(json, type) ?: emptyMap()
} catch (e: Exception) {
emptyMap()
}
}
private fun pruneOldItems(map: MutableMap<String, WatchProgress>): Map<String, WatchProgress> {
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 }
}
}

View file

@ -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<Any>, defaultTypes: List<String>): List<AddonResource> {
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<String>() ?: defaultTypes
val idPrefixes = (resource["idPrefixes"] as? List<*>)?.filterIsInstance<String>()
AddonResource(
name = name,
types = types,
idPrefixes = idPrefixes
)
}
else -> null
}
}
}

View file

@ -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()
)
}

View file

@ -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<String> {
return when (value) {
null -> emptyList()
is String -> listOf(value)
is List<*> -> value.filterIsInstance<String>()
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
)
}
}

View file

@ -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
)

View file

@ -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<AddonManifestDto>
@GET
suspend fun getCatalog(@Url catalogUrl: String): Response<CatalogResponseDto>
@GET
suspend fun getMeta(@Url metaUrl: String): Response<MetaResponseDto>
@GET
suspend fun getStreams(@Url streamUrl: String): Response<StreamResponseDto>
}

View file

@ -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<TmdbFindResponse>
@GET("movie/{movie_id}/external_ids")
suspend fun getMovieExternalIds(
@Path("movie_id") movieId: Int,
@Query("api_key") apiKey: String
): Response<TmdbExternalIdsResponse>
@GET("tv/{tv_id}/external_ids")
suspend fun getTvExternalIds(
@Path("tv_id") tvId: Int,
@Query("api_key") apiKey: String
): Response<TmdbExternalIdsResponse>
@GET("movie/{movie_id}")
suspend fun getMovieDetails(
@Path("movie_id") movieId: Int,
@Query("api_key") apiKey: String
): Response<TmdbDetailsResponse>
@GET("tv/{tv_id}")
suspend fun getTvDetails(
@Path("tv_id") tvId: Int,
@Query("api_key") apiKey: String
): Response<TmdbDetailsResponse>
@GET("movie/{movie_id}/credits")
suspend fun getMovieCredits(
@Path("movie_id") movieId: Int,
@Query("api_key") apiKey: String
): Response<TmdbCreditsResponse>
@GET("tv/{tv_id}/credits")
suspend fun getTvCredits(
@Path("tv_id") tvId: Int,
@Query("api_key") apiKey: String
): Response<TmdbCreditsResponse>
@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<TmdbImagesResponse>
@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<TmdbImagesResponse>
@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<TmdbSeasonResponse>
}
@JsonClass(generateAdapter = true)
data class TmdbFindResponse(
@Json(name = "movie_results") val movieResults: List<TmdbFindResult>? = null,
@Json(name = "tv_results") val tvResults: List<TmdbFindResult>? = null,
@Json(name = "tv_episode_results") val tvEpisodeResults: List<TmdbFindResult>? = null,
@Json(name = "tv_season_results") val tvSeasonResults: List<TmdbFindResult>? = 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<TmdbGenre>? = 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<Int>? = null,
@Json(name = "vote_average") val voteAverage: Double? = null,
@Json(name = "production_companies") val productionCompanies: List<TmdbCompany>? = null,
@Json(name = "networks") val networks: List<TmdbNetwork>? = null,
@Json(name = "production_countries") val productionCountries: List<TmdbCountry>? = null,
@Json(name = "origin_country") val originCountry: List<String>? = 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<TmdbCastMember>? = null,
@Json(name = "crew") val crew: List<TmdbCrewMember>? = 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<TmdbImage>? = 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<TmdbEpisode>? = 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
)

View file

@ -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<CatalogDescriptorDto> = emptyList(),
@Json(name = "resources") val resources: List<Any> = emptyList(),
@Json(name = "types") val types: List<String> = 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<ExtraDto>? = 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<String>? = null
)

View file

@ -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<MetaPreviewDto> = 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<String>? = null,
@Json(name = "runtime") val runtime: String? = null
)

View file

@ -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<String>? = 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<VideoDto>? = 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<MetaLinkDto>? = null,
@Json(name = "app_extras") val appExtras: AppExtrasDto? = null
)
@JsonClass(generateAdapter = true)
data class AppExtrasDto(
@Json(name = "cast") val cast: List<AppExtrasCastMemberDto>? = 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
)

View file

@ -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<StreamDto>? = 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<String>? = null,
@Json(name = "subtitles") val subtitles: List<SubtitleDto>? = 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<String>? = 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<String, String>? = null,
@Json(name = "response") val response: Map<String, String>? = 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
)

View file

@ -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<List<Addon>> =
preferences.installedAddonUrls.map { urls ->
val addons = mutableListOf<Addon>()
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<Addon> {
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)
}
}

View file

@ -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<String, String>
): Flow<NetworkResult<CatalogRow>> = 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, String>
): 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<String, String>()
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")
}
}

View file

@ -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<NetworkResult<Meta>> = 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<NetworkResult<Meta>> = 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"
}
}

View file

@ -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<NetworkResult<List<AddonStreams>>> = 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<AddonStreams>()
coroutineScope {
// Channel to receive results as they complete
val resultChannel = Channel<AddonStreams>(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<AddonStreams>,
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<String>()
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<List<Stream>> {
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))
}
}
}

View file

@ -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<List<WatchProgress>>
get() = watchProgressPreferences.allProgress
override val continueWatching: Flow<List<WatchProgress>>
get() = watchProgressPreferences.continueWatching
override fun getProgress(contentId: String): Flow<WatchProgress?> {
return watchProgressPreferences.getProgress(contentId)
}
override fun getEpisodeProgress(contentId: String, season: Int, episode: Int): Flow<WatchProgress?> {
return watchProgressPreferences.getEpisodeProgress(contentId, season, episode)
}
override fun getAllEpisodeProgress(contentId: String): Flow<Map<Pair<Int, Int>, 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()
}
}

View file

@ -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)
}
}

View file

@ -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<CatalogDescriptor>,
val types: List<ContentType>,
val resources: List<AddonResource>
)
data class CatalogDescriptor(
val type: ContentType,
val id: String,
val name: String,
val extra: List<CatalogExtra> = emptyList()
)
data class CatalogExtra(
val name: String,
val isRequired: Boolean = false,
val options: List<String>? = null
)
data class AddonResource(
val name: String,
val types: List<String>,
val idPrefixes: List<String>?
)

View file

@ -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<MetaPreview>,
val isLoading: Boolean = false,
val hasMore: Boolean = true,
val currentPage: Int = 0
)

View file

@ -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"
}
}

View file

@ -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<String>,
val runtime: String?,
val director: List<String>,
val writer: List<String> = emptyList(),
val cast: List<String>,
val castMembers: List<MetaCastMember> = emptyList(),
val videos: List<Video>,
val productionCompanies: List<MetaCompany> = emptyList(),
val networks: List<MetaCompany> = emptyList(),
val country: String?,
val awards: String?,
val language: String?,
val links: List<MetaLink>
)
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
)

View file

@ -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<String>
)

View file

@ -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<ScraperManifestInfo>
)
/**
* 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<String> = listOf("movie", "tv"),
val enabled: Boolean = true,
val logo: String? = null,
val contentLanguage: List<String>? = null,
val supportedPlatforms: List<String>? = null,
val disabledPlatforms: List<String>? = null,
val formats: List<String>? = null,
val supportedFormats: List<String>? = 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<String>,
val enabled: Boolean,
val manifestEnabled: Boolean,
val logo: String?,
val contentLanguage: List<String>,
val repositoryId: String,
val formats: List<String>?
) {
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<String, String>? = 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
)
}

View file

@ -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
}
}

View file

@ -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<String>,
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
)
}
}

View file

@ -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<String>?,
val proxyHeaders: ProxyHeaders?
)
data class ProxyHeaders(
val request: Map<String, String>?,
val response: Map<String, String>?
)
/**
* Represents streams grouped by addon source
*/
data class AddonStreams(
val addonName: String,
val addonLogo: String?,
val streams: List<Stream>
)

View file

@ -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
)

View file

@ -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")
)

View file

@ -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<List<Addon>>
suspend fun fetchAddon(baseUrl: String): NetworkResult<Addon>
suspend fun addAddon(url: String)
suspend fun removeAddon(url: String)
}

View file

@ -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<String, String> = emptyMap()
): Flow<NetworkResult<CatalogRow>>
}

View file

@ -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<NetworkResult<Meta>>
fun getMetaFromAllAddons(
type: String,
id: String
): Flow<NetworkResult<Meta>>
}

View file

@ -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<NetworkResult<List<AddonStreams>>>
/**
* 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<List<Stream>>
}

View file

@ -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<List<WatchProgress>>
/**
* Get items currently in progress (not completed, suitable for "Continue Watching")
*/
val continueWatching: Flow<List<WatchProgress>>
/**
* Get watch progress for a specific content item (movie or series)
*/
fun getProgress(contentId: String): Flow<WatchProgress?>
/**
* Get watch progress for a specific episode
*/
fun getEpisodeProgress(contentId: String, season: Int, episode: Int): Flow<WatchProgress?>
/**
* Get all episode progress for a series as a map of (season, episode) to progress
*/
fun getAllEpisodeProgress(contentId: String): Flow<Map<Pair<Int, Int>, 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()
}

View file

@ -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()
}
}
}
}
}
}

View file

@ -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
)
}
}
}
}

View file

@ -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<WatchProgress>,
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"
}
}

View file

@ -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")
}
}
}

View file

@ -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
)
}
}

View file

@ -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
)
}

View file

@ -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<SidebarItem>,
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
)
}
}

View file

@ -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() }
)
}
}
}

View file

@ -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<String, String>? = 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")
}

View file

@ -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
)
}
}
}

View file

@ -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<Addon> = emptyList(),
val error: String? = null
)

View file

@ -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<AddonManagerUiState> = _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
)
}
}
}
}
}

View file

@ -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<MetaCastMember>,
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
)
}
}
}

View file

@ -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<MetaCompany>
) {
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)
)
}
}
}

View file

@ -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) {
""
}
}
}

View file

@ -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<Int>,
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<Video>,
episodeProgressMap: Map<Pair<Int, Int>, 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
)
}
}
}
}
}

View file

@ -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
)
}

View file

@ -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<Int>,
selectedSeason: Int,
episodesForSeason: List<Video>,
isInLibrary: Boolean,
nextToWatch: NextToWatch?,
episodeProgressMap: Map<Pair<Int, Int>, 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
)
}
}
}
}
}

View file

@ -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<Int> = emptyList(),
val episodesForSeason: List<Video> = emptyList(),
val isInLibrary: Boolean = false,
val nextToWatch: NextToWatch? = null,
val episodeProgressMap: Map<Pair<Int, Int>, 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()
}

View file

@ -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<MetaDetailsUiState> = _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<Video>, season: Int): List<Video> {
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
}
}

View file

@ -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()
)
)
}
)
}
}
}
}
}
}

View file

@ -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<CatalogRow> = emptyList(),
val continueWatchingItems: List<WatchProgress> = 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()
}

View file

@ -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<HomeUiState> = _uiState.asStateFlow()
private val catalogsMap = linkedMapOf<String, CatalogRow>()
private val catalogOrder = mutableListOf<String>()
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 }
}
}

View file

@ -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)
)
}
}
}
}

View file

@ -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<LibraryUiState> = libraryPreferences.libraryItems
.map { items -> LibraryUiState(items = items) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
LibraryUiState()
)
}
data class LibraryUiState(
val items: List<SavedLibraryItem> = emptyList()
)

View file

@ -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<TrackInfo>,
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<TrackInfo>,
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)
}
}

View file

@ -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<TrackInfo> = emptyList(),
val subtitleTracks: List<TrackInfo> = 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)

View file

@ -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<String>("streamUrl")?.let {
URLDecoder.decode(it, "UTF-8")
} ?: ""
private val title: String = savedStateHandle.get<String>("title")?.let {
URLDecoder.decode(it, "UTF-8")
} ?: ""
private val headersJson: String? = savedStateHandle.get<String>("headers")?.let {
if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
}
// Watch progress metadata
private val contentId: String? = savedStateHandle.get<String>("contentId")?.let {
if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
}
private val contentType: String? = savedStateHandle.get<String>("contentType")?.let {
if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
}
private val contentName: String? = savedStateHandle.get<String>("contentName")?.let {
if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
}
private val poster: String? = savedStateHandle.get<String>("poster")?.let {
if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
}
private val backdrop: String? = savedStateHandle.get<String>("backdrop")?.let {
if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
}
private val logo: String? = savedStateHandle.get<String>("logo")?.let {
if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
}
private val videoId: String? = savedStateHandle.get<String>("videoId")?.let {
if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
}
private val season: Int? = savedStateHandle.get<String>("season")?.toIntOrNull()
private val episode: Int? = savedStateHandle.get<String>("episode")?.toIntOrNull()
private val episodeTitle: String? = savedStateHandle.get<String>("episodeTitle")?.let {
if (it.isNotEmpty()) URLDecoder.decode(it, "UTF-8") else null
}
private val _uiState = MutableStateFlow(PlayerUiState(title = title))
val uiState: StateFlow<PlayerUiState> = _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<String, String> {
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<TrackInfo>()
val subtitleTracks = mutableListOf<TrackInfo>()
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()
}
}

View file

@ -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<LocalScraperResult>?
) {
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))
}

View file

@ -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<PluginRepository> = emptyList(),
val scrapers: List<ScraperInfo> = emptyList(),
val isLoading: Boolean = false,
val isAddingRepo: Boolean = false,
val isTesting: Boolean = false,
val testResults: List<LocalScraperResult>? = null,
val testScraperId: String? = null,
val errorMessage: String? = null,
val successMessage: String? = null
)
sealed interface PluginUiEvent {
data class AddRepository(val url: String) : PluginUiEvent
data class RemoveRepository(val repoId: String) : PluginUiEvent
data class RefreshRepository(val repoId: String) : PluginUiEvent
data class ToggleScraper(val scraperId: String, val enabled: Boolean) : PluginUiEvent
data class TestScraper(val scraperId: String) : PluginUiEvent
data class SetPluginsEnabled(val enabled: Boolean) : PluginUiEvent
object ClearTestResults : PluginUiEvent
object ClearError : PluginUiEvent
object ClearSuccess : PluginUiEvent
}

Some files were not shown because too many files have changed in this diff Show more