mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-19 00:11:45 +00:00
Initial commit: NuvioTV as clean branch
This commit is contained in:
commit
d9c0381439
132 changed files with 13601 additions and 0 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
3
.idea/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
1
.idea/.name
Normal file
1
.idea/.name
Normal file
|
|
@ -0,0 +1 @@
|
|||
My Application
|
||||
6
.idea/AndroidProjectSystem.xml
Normal file
6
.idea/AndroidProjectSystem.xml
Normal 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
6
.idea/compiler.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/copilot.data.migration.agent.xml
Normal file
6
.idea/copilot.data.migration.agent.xml
Normal 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>
|
||||
6
.idea/copilot.data.migration.ask.xml
Normal file
6
.idea/copilot.data.migration.ask.xml
Normal 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>
|
||||
6
.idea/copilot.data.migration.ask2agent.xml
Normal file
6
.idea/copilot.data.migration.ask2agent.xml
Normal 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>
|
||||
6
.idea/copilot.data.migration.edit.xml
Normal file
6
.idea/copilot.data.migration.edit.xml
Normal 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>
|
||||
18
.idea/deploymentTargetSelector.xml
Normal file
18
.idea/deploymentTargetSelector.xml
Normal 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
13
.idea/deviceManager.xml
Normal 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
19
.idea/gradle.xml
Normal 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>
|
||||
50
.idea/inspectionProfiles/Project_Default.xml
Normal file
50
.idea/inspectionProfiles/Project_Default.xml
Normal 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
10
.idea/migrations.xml
Normal 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
9
.idea/misc.xml
Normal 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>
|
||||
17
.idea/runConfigurations.xml
Normal file
17
.idea/runConfigurations.xml
Normal 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
10
.idea/vcs.xml
Normal 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>
|
||||
4
.kotlin/errors/errors-1769668942713.log
Normal file
4
.kotlin/errors/errors-1769668942713.log
Normal 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
1
app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
115
app/build.gradle.kts
Normal file
115
app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
BIN
app/libs/media3-decoder-ffmpeg.aar
Normal file
BIN
app/libs/media3-decoder-ffmpeg.aar
Normal file
Binary file not shown.
21
app/proguard-rules.pro
vendored
Normal file
21
app/proguard-rules.pro
vendored
Normal 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
|
||||
34
app/src/main/AndroidManifest.xml
Normal file
34
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
179
app/src/main/java/com/nuvio/tv/MainActivity.kt
Normal file
179
app/src/main/java/com/nuvio/tv/MainActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app/src/main/java/com/nuvio/tv/NuvioApplication.kt
Normal file
7
app/src/main/java/com/nuvio/tv/NuvioApplication.kt
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package com.nuvio.tv
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class NuvioApplication : Application()
|
||||
67
app/src/main/java/com/nuvio/tv/core/di/NetworkModule.kt
Normal file
67
app/src/main/java/com/nuvio/tv/core/di/NetworkModule.kt
Normal 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)
|
||||
}
|
||||
42
app/src/main/java/com/nuvio/tv/core/di/RepositoryModule.kt
Normal file
42
app/src/main/java/com/nuvio/tv/core/di/RepositoryModule.kt
Normal 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
|
||||
}
|
||||
|
|
@ -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>()
|
||||
}
|
||||
18
app/src/main/java/com/nuvio/tv/core/network/SafeApiCall.kt
Normal file
18
app/src/main/java/com/nuvio/tv/core/network/SafeApiCall.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
483
app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt
Normal file
483
app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
1029
app/src/main/java/com/nuvio/tv/core/plugin/PluginRuntime.kt
Normal file
1029
app/src/main/java/com/nuvio/tv/core/plugin/PluginRuntime.kt
Normal file
File diff suppressed because it is too large
Load diff
212
app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt
Normal file
212
app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt
Normal 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
|
||||
)
|
||||
}
|
||||
215
app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt
Normal file
215
app/src/main/java/com/nuvio/tv/core/tmdb/TmdbService.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
186
app/src/main/java/com/nuvio/tv/data/local/PluginDataStore.kt
Normal file
186
app/src/main/java/com/nuvio/tv/data/local/PluginDataStore.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
66
app/src/main/java/com/nuvio/tv/data/mapper/AddonMapper.kt
Normal file
66
app/src/main/java/com/nuvio/tv/data/mapper/AddonMapper.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
app/src/main/java/com/nuvio/tv/data/mapper/CatalogMapper.kt
Normal file
21
app/src/main/java/com/nuvio/tv/data/mapper/CatalogMapper.kt
Normal 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()
|
||||
)
|
||||
}
|
||||
85
app/src/main/java/com/nuvio/tv/data/mapper/MetaMapper.kt
Normal file
85
app/src/main/java/com/nuvio/tv/data/mapper/MetaMapper.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
34
app/src/main/java/com/nuvio/tv/data/mapper/StreamMapper.kt
Normal file
34
app/src/main/java/com/nuvio/tv/data/mapper/StreamMapper.kt
Normal 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
|
||||
)
|
||||
24
app/src/main/java/com/nuvio/tv/data/remote/api/AddonApi.kt
Normal file
24
app/src/main/java/com/nuvio/tv/data/remote/api/AddonApi.kt
Normal 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>
|
||||
}
|
||||
190
app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt
Normal file
190
app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt
Normal 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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
30
app/src/main/java/com/nuvio/tv/di/PluginModule.kt
Normal file
30
app/src/main/java/com/nuvio/tv/di/PluginModule.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
33
app/src/main/java/com/nuvio/tv/domain/model/Addon.kt
Normal file
33
app/src/main/java/com/nuvio/tv/domain/model/Addon.kt
Normal 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>?
|
||||
)
|
||||
|
||||
14
app/src/main/java/com/nuvio/tv/domain/model/CatalogRow.kt
Normal file
14
app/src/main/java/com/nuvio/tv/domain/model/CatalogRow.kt
Normal 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
|
||||
)
|
||||
27
app/src/main/java/com/nuvio/tv/domain/model/ContentType.kt
Normal file
27
app/src/main/java/com/nuvio/tv/domain/model/ContentType.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
55
app/src/main/java/com/nuvio/tv/domain/model/Meta.kt
Normal file
55
app/src/main/java/com/nuvio/tv/domain/model/Meta.kt
Normal 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
|
||||
)
|
||||
14
app/src/main/java/com/nuvio/tv/domain/model/MetaPreview.kt
Normal file
14
app/src/main/java/com/nuvio/tv/domain/model/MetaPreview.kt
Normal 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>
|
||||
)
|
||||
132
app/src/main/java/com/nuvio/tv/domain/model/Plugin.kt
Normal file
132
app/src/main/java/com/nuvio/tv/domain/model/Plugin.kt
Normal 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
|
||||
)
|
||||
}
|
||||
21
app/src/main/java/com/nuvio/tv/domain/model/PosterShape.kt
Normal file
21
app/src/main/java/com/nuvio/tv/domain/model/PosterShape.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
69
app/src/main/java/com/nuvio/tv/domain/model/Stream.kt
Normal file
69
app/src/main/java/com/nuvio/tv/domain/model/Stream.kt
Normal 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>
|
||||
)
|
||||
19
app/src/main/java/com/nuvio/tv/domain/model/TmdbSettings.kt
Normal file
19
app/src/main/java/com/nuvio/tv/domain/model/TmdbSettings.kt
Normal 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
|
||||
)
|
||||
62
app/src/main/java/com/nuvio/tv/domain/model/WatchProgress.kt
Normal file
62
app/src/main/java/com/nuvio/tv/domain/model/WatchProgress.kt
Normal 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")
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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>>
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt
Normal file
128
app/src/main/java/com/nuvio/tv/ui/components/ContentCard.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
47
app/src/main/java/com/nuvio/tv/ui/components/ErrorState.kt
Normal file
47
app/src/main/java/com/nuvio/tv/ui/components/ErrorState.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
67
app/src/main/java/com/nuvio/tv/ui/components/NuvioTopBar.kt
Normal file
67
app/src/main/java/com/nuvio/tv/ui/components/NuvioTopBar.kt
Normal 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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
263
app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt
Normal file
263
app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt
Normal 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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
88
app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt
Normal file
88
app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt
Normal 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")
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
146
app/src/main/java/com/nuvio/tv/ui/screens/detail/CastSection.kt
Normal file
146
app/src/main/java/com/nuvio/tv/ui/screens/detail/CastSection.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
337
app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt
Normal file
337
app/src/main/java/com/nuvio/tv/ui/screens/detail/HeroSection.kt
Normal 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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
102
app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt
Normal file
102
app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt
Normal 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()
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
193
app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt
Normal file
193
app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
)
|
||||
868
app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt
Normal file
868
app/src/main/java/com/nuvio/tv/ui/screens/player/PlayerScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
768
app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginScreen.kt
Normal file
768
app/src/main/java/com/nuvio/tv/ui/screens/plugin/PluginScreen.kt
Normal 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))
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in a new issue