mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-19 08:22:00 +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