Compare commits
	
		
			75 Commits
		
	
	
		
			dev-optimi
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | c3e0a23ed1 | ||
|   | 7be18dd517 | ||
|   | a13ddbdd87 | ||
|   | 390094b8b0 | ||
|   | b360724dca | ||
|   | db8d5ef4d5 | ||
|   | 2c4d5d2366 | ||
|   | 200cf33e5a | ||
|   | 603c2c38aa | ||
|   | 7c99ea394b | ||
|   | 614a0d591d | ||
|   | 24dda0eb2c | ||
|   | c5a5826321 | ||
|   | ba4811e65f | ||
|   | 8b5adfd6b7 | ||
|   | 02d8d30da7 | ||
|   | 422da51a74 | ||
|   | 393419afd7 | ||
|   | 88392444a4 | ||
|   | 2166229923 | ||
|   | 9bad0dcbc2 | ||
|   | c21defb426 | ||
|   | e6b69ef14a | ||
|   | dcef25a526 | ||
|   | cf0c68812d | ||
|   | 22469e1d49 | ||
|   | ba1a7c9a92 | ||
|   | 584fc1f785 | ||
|   | 8184ab211c | ||
|   | 4e346a83ee | ||
|   | 5b770a965d | ||
|   | a89f892306 | ||
|   | e38d77b2f6 | ||
|   | 756c2ea9f8 | ||
|   | c9c3306766 | ||
|   | 49751c55d9 | ||
|   | d918508c16 | ||
|   | d858cd18bd | ||
|   | 82f537038c | ||
|   | a298cb75e2 | ||
|   | 92f0e8543e | ||
|   | f78bcc83c9 | ||
|   | 55ea2e1ae3 | ||
|   | 947ffc4599 | ||
|   | 1b24312a95 | ||
|   | a15325deeb | ||
|   | c402e18206 | ||
|   | 2260f26d9a | ||
|   | 829804abee | ||
|   | e94249aa8f | ||
|   | ad51c5da2f | ||
|   | e6788d801a | ||
|   | 54c9d326c6 | ||
|   | f7701cc85b | ||
|   | cc540903d3 | ||
|   | 9c04d7679c | ||
|   | c330a1e70c | ||
|   | ffa70d9d34 | ||
|   | 7d07f19440 | ||
|   | b4e73c4212 | ||
|   | d28804178e | ||
|   | 10f316cb48 | ||
|   | b48f8ce6b0 | ||
|   | f6583ffcf1 | ||
|   | aacd226260 | ||
|   | 514e99d7db | ||
|   | 18d021a8e5 | ||
|   | daa66a9ecc | ||
|   | ea574895ab | ||
|   | 06ada999c3 | ||
|   | e249ae27c9 | ||
|   | 1a301770e2 | ||
|   | 0067f3000b | ||
|   | b5940aecc3 | ||
|   | 76054da910 | 
							
								
								
									
										40
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -6,11 +6,33 @@ | |||||||
|  |  | ||||||
| _🚀This is the client of the multimedia server Abyss, which can also be extended to other purposes🚀_ | _🚀This is the client of the multimedia server Abyss, which can also be extended to other purposes🚀_ | ||||||
|  |  | ||||||
|  | <img src="aether_clip.png" width="25%" alt="Logo"> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <br/> | ## 🎯 Target | ||||||
| <br/> |  | ||||||
| <br/> | The ultimate goal of this software project is to enable anyone to easily build a smooth media library that they can fully manage and control,  | ||||||
|  | contribute to with trusted individuals, and securely access from any location without worrying about unauthorized use of their data by third parties.  | ||||||
|  | Undoubtedly, this is a distant goal, but in any case,  | ||||||
|  | I hope this project can make a modest contribution to the advancement of cybersecurity and the protection of user privacy. | ||||||
|  |  | ||||||
|  | ## Key Features | ||||||
|  |  | ||||||
|  | - **Media Management**: Organize and serve images, videos, and live streams with structured directory support. | ||||||
|  | - **User Authentication**: Challenge-response authentication using **Ed25519** signatures. No private keys are ever transmitted. | ||||||
|  | - **Role-Based Access Control (RBAC)**: Hierarchical user privileges with configurable permissions for resources. | ||||||
|  | - **Secure Proxy**: Built-in HTTP/S proxy with end-to-end encrypted tunneling using **X25519** key exchange and **ChaCha20-Poly1305** encryption. | ||||||
|  | - **Resource-Level Permissions**: Fine-grained control over files and directories using a SQLite-based attribute system. | ||||||
|  | - **Task System**: Support for background tasks such as media uploads and processing with chunk-based integrity validation. | ||||||
|  | - **RESTful API**: Fully documented API endpoints for media access, user management, and task control. | ||||||
|  |  | ||||||
|  | ## Technology Stack | ||||||
|  |  | ||||||
|  | - **Backend**: ASP.NET Core 9, MVC, Dependency Injection | ||||||
|  | - **Database**: SQLite with async ORM support | ||||||
|  | - **Cryptography**: NSec.Cryptography (Ed25519, X25519), ChaCha20Poly1305 | ||||||
|  | - **Media Handling**: Range requests, MIME type detection, chunked uploads | ||||||
|  | - **Security**: Rate limiting, IP binding, token expiration, secure headers | ||||||
|  |  | ||||||
| ## Development background | ## Development background | ||||||
|  |  | ||||||
| @@ -26,15 +48,15 @@ _🚀This is the client of the multimedia server Abyss, which can also be extend | |||||||
| - [x] Hide private key after user input | - [x] Hide private key after user input | ||||||
| - [x] Optimize API call logic, do not create crashes | - [x] Optimize API call logic, do not create crashes | ||||||
| - [x] Fix the issue of freezing when entering the client without configuring the private key | - [x] Fix the issue of freezing when entering the client without configuring the private key | ||||||
| - [ ] Replace Android robot icon with custom design | - [x] Replace Android robot icon with custom design | ||||||
| - [ ] Configure server baseURL in client settings | - [x] Configure server baseURL in client settings | ||||||
| - [ ] Implement proper access control for directory queries | - [ ] Implement proper access control for directory queries | ||||||
|  |  | ||||||
| ### Medium Priority | ### Medium Priority | ||||||
| - [ ] Increase minHeight for video playback | - [x] Increase minHeight for video playback | ||||||
| - [ ] Add top bar with title and back button in full-screen mode | - [x] Add top bar with title and back button in full-screen mode | ||||||
| - [ ] Optimize data transfer system | - [x] Optimize data transfer system | ||||||
| - [ ] Improve manga/comic page display | - [x] Improve manga/comic page display | ||||||
|  |  | ||||||
| ### Future | ### Future | ||||||
| - [ ] (Prospective) Implement search functionality | - [ ] (Prospective) Implement search functionality | ||||||
							
								
								
									
										
											BIN
										
									
								
								aether.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 616 KiB | 
							
								
								
									
										
											BIN
										
									
								
								aether_clip.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 348 KiB | 
| @@ -3,6 +3,9 @@ plugins { | |||||||
|     alias(libs.plugins.kotlin.android) |     alias(libs.plugins.kotlin.android) | ||||||
|     alias(libs.plugins.kotlin.compose) |     alias(libs.plugins.kotlin.compose) | ||||||
|     kotlin("plugin.serialization") version "1.9.0" |     kotlin("plugin.serialization") version "1.9.0" | ||||||
|  |  | ||||||
|  |     alias(libs.plugins.ksp) | ||||||
|  |     alias(libs.plugins.hilt.android) | ||||||
| } | } | ||||||
|  |  | ||||||
| android { | android { | ||||||
| @@ -30,11 +33,12 @@ android { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     compileOptions { |     compileOptions { | ||||||
|         sourceCompatibility = JavaVersion.VERSION_11 |         sourceCompatibility = JavaVersion.VERSION_21 | ||||||
|         targetCompatibility = JavaVersion.VERSION_11 |         targetCompatibility = JavaVersion.VERSION_21 | ||||||
|     } |     } | ||||||
|     kotlinOptions { |     kotlinOptions { | ||||||
|         jvmTarget = "11" |         jvmTarget = "21" | ||||||
|  |         freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode") | ||||||
|     } |     } | ||||||
|     buildFeatures { |     buildFeatures { | ||||||
|         compose = true |         compose = true | ||||||
| @@ -42,10 +46,27 @@ android { | |||||||
| } | } | ||||||
|  |  | ||||||
| dependencies { | dependencies { | ||||||
|  |     implementation(libs.persistentcookiejar) | ||||||
|  |  | ||||||
|  |     implementation(libs.fetch2) | ||||||
|  |     implementation(libs.fetch2okhttp) | ||||||
|  |  | ||||||
|  |     implementation(libs.hilt.android) | ||||||
|  |     implementation(libs.hilt.navigation.compose) | ||||||
|  |     implementation(libs.androidx.compose.material.core) | ||||||
|  |     implementation(libs.androidx.constraintlayout) | ||||||
|  |     implementation(libs.androidx.compose.animation) | ||||||
|  |     ksp(libs.hilt.android.compiler) | ||||||
|  |  | ||||||
|  |     implementation(libs.androidx.room.runtime) | ||||||
|  |     implementation(libs.androidx.room.ktx) | ||||||
|  |     ksp(libs.androidx.room.compiler) | ||||||
|  |  | ||||||
|     implementation(libs.androidx.datastore.preferences) |     implementation(libs.androidx.datastore.preferences) | ||||||
|     implementation(libs.bcprov.jdk15on) |     implementation(libs.bcprov.jdk15on) | ||||||
|     implementation(libs.converter.gson) |     implementation(libs.converter.gson) | ||||||
|     implementation(libs.gson) |     implementation(libs.gson) | ||||||
|  |  | ||||||
|     implementation(libs.androidx.media3.exoplayer) |     implementation(libs.androidx.media3.exoplayer) | ||||||
|     implementation(libs.androidx.media3.ui) |     implementation(libs.androidx.media3.ui) | ||||||
|     implementation(libs.androidx.media3.common) |     implementation(libs.androidx.media3.common) | ||||||
|   | |||||||
| @@ -12,22 +12,31 @@ | |||||||
|         android:usesCleartextTraffic="true" |         android:usesCleartextTraffic="true" | ||||||
|         android:dataExtractionRules="@xml/data_extraction_rules" |         android:dataExtractionRules="@xml/data_extraction_rules" | ||||||
|         android:fullBackupContent="@xml/backup_rules" |         android:fullBackupContent="@xml/backup_rules" | ||||||
|         android:icon="@mipmap/ic_launcher" |         android:icon="@mipmap/aether" | ||||||
|         android:label="@string/app_name" |         android:label="@string/app_name" | ||||||
|         android:roundIcon="@mipmap/ic_launcher_round" |         android:roundIcon="@mipmap/aether_round" | ||||||
|         android:supportsRtl="true" |         android:supportsRtl="true" | ||||||
|         android:theme="@style/Theme.Aether" |         android:theme="@style/Theme.Aether" | ||||||
|         android:name=".AetherApp"> |         android:name=".AetherApp"> | ||||||
|         <activity |         <activity | ||||||
|             android:name=".MainActivity" |             android:name=".MainActivity" | ||||||
|             android:exported="true" |             android:exported="true" | ||||||
|             android:label="@string/app_name" |  | ||||||
|             android:theme="@style/Theme.Aether"> |             android:theme="@style/Theme.Aether"> | ||||||
|             <intent-filter> |             <intent-filter> | ||||||
|                 <action android:name="android.intent.action.MAIN" /> |                 <action android:name="android.intent.action.MAIN" /> | ||||||
|  |  | ||||||
|                 <category android:name="android.intent.category.LAUNCHER" /> |                 <category android:name="android.intent.category.LAUNCHER" /> | ||||||
|             </intent-filter> |             </intent-filter> | ||||||
|         </activity> |         </activity> | ||||||
|  |  | ||||||
|  |         <activity | ||||||
|  |             android:name=".MainScreenActivity" | ||||||
|  |             android:exported="true" | ||||||
|  |             android:theme="@style/Theme.Aether"> | ||||||
|  |         </activity> | ||||||
|  |  | ||||||
|  |         <service | ||||||
|  |             android:name=".AbyssService" | ||||||
|  |             android:enabled="true" | ||||||
|  |             android:exported="false" /> | ||||||
|     </application> |     </application> | ||||||
| </manifest> | </manifest> | ||||||
							
								
								
									
										
											BIN
										
									
								
								app/src/main/aether-playstore.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 163 KiB | 
							
								
								
									
										49
									
								
								app/src/main/java/com/acitelight/aether/AbyssService.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,49 @@ | |||||||
|  | package com.acitelight.aether | ||||||
|  |  | ||||||
|  | import android.app.Service | ||||||
|  | import android.content.Intent | ||||||
|  | import android.os.Binder | ||||||
|  | import android.os.IBinder | ||||||
|  | import com.acitelight.aether.service.AbyssTunnelProxy | ||||||
|  | import com.acitelight.aether.service.FetchManager | ||||||
|  | import com.acitelight.aether.service.SettingsDataStoreManager | ||||||
|  | import dagger.hilt.android.AndroidEntryPoint | ||||||
|  | import kotlinx.coroutines.CoroutineScope | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.Job | ||||||
|  | import kotlinx.coroutines.flow.MutableStateFlow | ||||||
|  | import kotlinx.coroutines.flow.StateFlow | ||||||
|  | import kotlinx.coroutines.flow.asStateFlow | ||||||
|  | import kotlinx.coroutines.flow.update | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
|  | @AndroidEntryPoint | ||||||
|  | class AbyssService: Service() { | ||||||
|  |     @Inject | ||||||
|  |     lateinit var proxy: AbyssTunnelProxy | ||||||
|  |     @Inject | ||||||
|  |     lateinit var downloader: FetchManager | ||||||
|  |  | ||||||
|  |     private val binder = AbyssServiceBinder() | ||||||
|  |     private val _isInitialized = MutableStateFlow(false) | ||||||
|  |     val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow() | ||||||
|  |     private val serviceScope = CoroutineScope(Dispatchers.IO + Job()) | ||||||
|  |  | ||||||
|  |     inner class AbyssServiceBinder : Binder() { | ||||||
|  |         fun getService(): AbyssService = this@AbyssService | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override fun onBind(intent: Intent?): IBinder { | ||||||
|  |         return binder | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override fun onCreate() { | ||||||
|  |         super.onCreate() | ||||||
|  |  | ||||||
|  |         serviceScope.launch { | ||||||
|  |             _isInitialized.update { true } | ||||||
|  |             proxy.start() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,15 +1,51 @@ | |||||||
| package com.acitelight.aether | package com.acitelight.aether | ||||||
|  |  | ||||||
| import android.app.Application | import android.app.Application | ||||||
|  | import android.content.ComponentName | ||||||
| import android.content.Context | import android.content.Context | ||||||
|  | import android.content.Intent | ||||||
|  | import android.content.ServiceConnection | ||||||
|  | import android.os.IBinder | ||||||
| import androidx.datastore.core.DataStore | import androidx.datastore.core.DataStore | ||||||
| import androidx.datastore.preferences.core.Preferences | import androidx.datastore.preferences.core.Preferences | ||||||
| import androidx.datastore.preferences.preferencesDataStore | import androidx.datastore.preferences.preferencesDataStore | ||||||
|  | import dagger.Module | ||||||
|  | import dagger.Provides | ||||||
|  | import dagger.hilt.InstallIn | ||||||
|  | import dagger.hilt.android.HiltAndroidApp | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import dagger.hilt.components.SingletonComponent | ||||||
|  | import kotlinx.coroutines.flow.StateFlow | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
| val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "configure") | val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "configure") | ||||||
|  |  | ||||||
|  | @HiltAndroidApp | ||||||
| class AetherApp : Application() { | class AetherApp : Application() { | ||||||
|  |     var abyssService: AbyssService? = null | ||||||
|  |     var isServiceBound = false | ||||||
|  |         private set | ||||||
|  |  | ||||||
|  |     val isServiceInitialized: StateFlow<Boolean>? | ||||||
|  |         get() = abyssService?.isInitialized | ||||||
|  |  | ||||||
|  |     private val serviceConnection = object : ServiceConnection { | ||||||
|  |         override fun onServiceConnected(name: ComponentName?, service: IBinder?) { | ||||||
|  |             val binder = service as AbyssService.AbyssServiceBinder | ||||||
|  |             abyssService = binder.getService() | ||||||
|  |             isServiceBound = true | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onServiceDisconnected(name: ComponentName?) { | ||||||
|  |             isServiceBound = false | ||||||
|  |             abyssService = null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     override fun onCreate() { |     override fun onCreate() { | ||||||
|         super.onCreate() |         super.onCreate() | ||||||
|  |  | ||||||
|  |         val intent = Intent(this, AbyssService::class.java) | ||||||
|  |         bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -8,4 +8,18 @@ import com.acitelight.aether.model.Video | |||||||
| object Global { | object Global { | ||||||
|     var loggedIn by mutableStateOf(false) |     var loggedIn by mutableStateOf(false) | ||||||
|     var sameClassVideos: List<Video>? = null |     var sameClassVideos: List<Video>? = null | ||||||
|  |     private set | ||||||
|  |  | ||||||
|  |     var isFullScreen by mutableStateOf(false) | ||||||
|  |     fun updateRelate(v: List<Video>, s: Video) | ||||||
|  |     { | ||||||
|  |         sameClassVideos = if (v.contains(s)) { | ||||||
|  |             val index = v.indexOf(s) | ||||||
|  |             val afterS = v.subList(index, v.size) | ||||||
|  |             val beforeS = v.subList(0, index) | ||||||
|  |             afterS + beforeS | ||||||
|  |         } else { | ||||||
|  |             v | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -1,43 +1,48 @@ | |||||||
| package com.acitelight.aether | package com.acitelight.aether | ||||||
|  |  | ||||||
| import android.app.Activity | import android.app.Activity | ||||||
|  | import android.content.Intent | ||||||
| import androidx.compose.material.icons.Icons | import androidx.compose.material.icons.Icons | ||||||
| import android.graphics.drawable.Icon |  | ||||||
| import android.net.http.SslCertificate.saveState |  | ||||||
| import android.os.Bundle | import android.os.Bundle | ||||||
|  | import android.view.View | ||||||
|  | import android.view.WindowManager | ||||||
| import androidx.activity.ComponentActivity | import androidx.activity.ComponentActivity | ||||||
| import androidx.activity.compose.setContent | import androidx.activity.compose.setContent | ||||||
| import androidx.activity.enableEdgeToEdge | import androidx.activity.enableEdgeToEdge | ||||||
|  | import androidx.compose.animation.AnimatedContentTransitionScope | ||||||
| import androidx.compose.animation.AnimatedVisibility | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | import androidx.compose.animation.ExperimentalAnimationApi | ||||||
|  | import androidx.compose.animation.core.tween | ||||||
| import androidx.compose.animation.slideInVertically | import androidx.compose.animation.slideInVertically | ||||||
| import androidx.compose.animation.slideOutVertically | import androidx.compose.animation.slideOutVertically | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.isSystemInDarkTheme | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
| import androidx.compose.foundation.layout.fillMaxSize | import androidx.compose.foundation.layout.fillMaxSize | ||||||
| import androidx.compose.foundation.layout.height | import androidx.compose.foundation.layout.height | ||||||
| import androidx.compose.foundation.layout.heightIn |  | ||||||
| import androidx.compose.foundation.layout.padding | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
| import androidx.compose.material.icons.automirrored.filled.CompareArrows | import androidx.compose.material.icons.automirrored.filled.CompareArrows | ||||||
| import androidx.compose.material.icons.filled.* | import androidx.compose.material.icons.filled.* | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
| import androidx.compose.material3.Icon | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.NavigationBar | import androidx.compose.material3.NavigationBar | ||||||
| import androidx.compose.material3.NavigationBarItem | import androidx.compose.material3.NavigationBarItem | ||||||
| import androidx.compose.material3.Scaffold | import androidx.compose.material3.Scaffold | ||||||
| import androidx.compose.material3.SegmentedButtonDefaults.Icon |  | ||||||
| import androidx.compose.material3.Text |  | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.Composable | ||||||
| import androidx.compose.runtime.LaunchedEffect | import androidx.compose.runtime.LaunchedEffect | ||||||
| import androidx.compose.runtime.getValue | 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.Modifier | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
| import androidx.compose.ui.graphics.vector.ImageVector | import androidx.compose.ui.graphics.vector.ImageVector | ||||||
| import androidx.compose.ui.platform.LocalView | import androidx.compose.ui.platform.LocalView | ||||||
| import androidx.compose.ui.res.painterResource |  | ||||||
| import androidx.compose.ui.tooling.preview.Preview |  | ||||||
| import androidx.compose.ui.unit.dp | import androidx.compose.ui.unit.dp | ||||||
| import androidx.core.view.WindowCompat | import androidx.core.view.WindowCompat | ||||||
| import androidx.core.view.WindowInsetsCompat | import androidx.core.view.WindowInsetsCompat | ||||||
| import androidx.core.view.WindowInsetsControllerCompat | import androidx.core.view.WindowInsetsControllerCompat | ||||||
|  | import androidx.lifecycle.lifecycleScope | ||||||
| import androidx.navigation.NavController | import androidx.navigation.NavController | ||||||
| import androidx.navigation.NavGraph.Companion.findStartDestination | import androidx.navigation.NavGraph.Companion.findStartDestination | ||||||
| import androidx.navigation.NavType | import androidx.navigation.NavType | ||||||
| @@ -47,15 +52,43 @@ import androidx.navigation.compose.composable | |||||||
| import androidx.navigation.compose.currentBackStackEntryAsState | import androidx.navigation.compose.currentBackStackEntryAsState | ||||||
| import androidx.navigation.compose.rememberNavController | import androidx.navigation.compose.rememberNavController | ||||||
| import androidx.navigation.navArgument | import androidx.navigation.navArgument | ||||||
| import com.acitelight.aether.view.ComicScreen | import com.acitelight.aether.view.pages.ComicGridView | ||||||
| import com.acitelight.aether.view.HomeScreen | import com.acitelight.aether.view.pages.ComicPageView | ||||||
| import com.acitelight.aether.view.MeScreen | import com.acitelight.aether.view.pages.ComicScreen | ||||||
| import com.acitelight.aether.view.VideoPlayer | import com.acitelight.aether.view.pages.HomeScreen | ||||||
| import com.acitelight.aether.view.VideoScreen | import com.acitelight.aether.view.pages.LiveScreen | ||||||
|  | import com.acitelight.aether.view.pages.MeScreen | ||||||
|  | import com.acitelight.aether.view.pages.TransmissionScreen | ||||||
|  | import com.acitelight.aether.view.pages.VideoPlayer | ||||||
|  | import com.acitelight.aether.view.pages.VideoScreen | ||||||
|  | import dagger.hilt.android.AndroidEntryPoint | ||||||
|  | import kotlinx.coroutines.flow.filter | ||||||
|  | import kotlinx.coroutines.flow.first | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  |  | ||||||
|  | @AndroidEntryPoint | ||||||
| class MainActivity : ComponentActivity() { | class MainActivity : ComponentActivity() { | ||||||
|     override fun onCreate(savedInstanceState: Bundle?) { |     override fun onCreate(savedInstanceState: Bundle?) { | ||||||
|         super.onCreate(savedInstanceState) |         super.onCreate(savedInstanceState) | ||||||
|  |  | ||||||
|  |         val app = application as AetherApp | ||||||
|  |  | ||||||
|  |         lifecycleScope.launch { | ||||||
|  |             app.isServiceInitialized?.filter { it }?.first() | ||||||
|  |             val intent = Intent(this@MainActivity, MainScreenActivity::class.java) | ||||||
|  |             startActivity(intent) | ||||||
|  |             finish() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @AndroidEntryPoint | ||||||
|  | class MainScreenActivity : ComponentActivity() { | ||||||
|  |     override fun onCreate(savedInstanceState: Bundle?) { | ||||||
|  |         super.onCreate(savedInstanceState) | ||||||
|  |         window.attributes = window.attributes.apply { | ||||||
|  |             screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE | ||||||
|  |         } | ||||||
|         enableEdgeToEdge() |         enableEdgeToEdge() | ||||||
|         WindowCompat.setDecorFitsSystemWindows(window, false) |         WindowCompat.setDecorFitsSystemWindows(window, false) | ||||||
|         setContent { |         setContent { | ||||||
| @@ -67,22 +100,17 @@ class MainActivity : ComponentActivity() { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @Composable | fun setFullScreen(view: View, isFullScreen: Boolean) { | ||||||
| fun ToggleFullScreen(isFullScreen: Boolean) |     Global.isFullScreen = isFullScreen | ||||||
| { |     val window = (view.context as Activity).window | ||||||
|     val view = LocalView.current |     val insetsController = WindowCompat.getInsetsController(window, view) | ||||||
|  |  | ||||||
|     LaunchedEffect(isFullScreen) { |     if (isFullScreen) { | ||||||
|         val window = (view.context as Activity).window |         insetsController.hide(WindowInsetsCompat.Type.systemBars()) | ||||||
|         val insetsController = WindowCompat.getInsetsController(window, view) |         insetsController.systemBarsBehavior = | ||||||
|  |             WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE | ||||||
|         if (isFullScreen) { |     } else { | ||||||
|             insetsController.hide(WindowInsetsCompat.Type.systemBars()) |         insetsController.show(WindowInsetsCompat.Type.systemBars()) | ||||||
|             insetsController.systemBarsBehavior = |  | ||||||
|                 WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE |  | ||||||
|         } else { |  | ||||||
|             insetsController.show(WindowInsetsCompat.Type.systemBars()) |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -94,6 +122,8 @@ fun AppNavigation() { | |||||||
|  |  | ||||||
|     val hideBottomBarRoutes = listOf( |     val hideBottomBarRoutes = listOf( | ||||||
|         Screen.VideoPlayer.route, |         Screen.VideoPlayer.route, | ||||||
|  |         Screen.ComicGrid.route, | ||||||
|  |         Screen.ComicPage.route | ||||||
|     ) |     ) | ||||||
|     val shouldShowBottomBar = currentRoute !in hideBottomBarRoutes |     val shouldShowBottomBar = currentRoute !in hideBottomBarRoutes | ||||||
|  |  | ||||||
| @@ -106,34 +136,73 @@ fun AppNavigation() { | |||||||
|             ) { |             ) { | ||||||
|                 BottomNavigationBar(navController = navController) |                 BottomNavigationBar(navController = navController) | ||||||
|             } |             } | ||||||
|             if(shouldShowBottomBar) |  | ||||||
|                 ToggleFullScreen(false) |  | ||||||
|         } |         } | ||||||
|     ) { innerPadding -> |     ) { innerPadding -> | ||||||
|         NavHost( |         NavHost( | ||||||
|             navController = navController, |             navController = navController, | ||||||
|             startDestination = Screen.Home.route, |             startDestination = Screen.Me.route, | ||||||
|             modifier = if(shouldShowBottomBar)Modifier.padding(innerPadding) else Modifier.padding(0.dp) |             modifier = if(!Global.isFullScreen) Modifier.padding(innerPadding) else Modifier.padding(0.dp) | ||||||
|         ) { |         ) { | ||||||
|             composable(Screen.Home.route) { |             composable( | ||||||
|                 HomeScreen(navController = navController) |                 Screen.Home.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) } | ||||||
|  |             ) { | ||||||
|  |                 CardPage(title = "Home") { | ||||||
|  |                     HomeScreen(navController = navController) | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|             composable(Screen.Video.route) { |             composable(Screen.Video.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) { | ||||||
|                 VideoScreen(navController = navController) |                 VideoScreen(navController = navController) | ||||||
|             } |             } | ||||||
|             composable(Screen.Comic.route) { |             composable(Screen.Comic.route, | ||||||
|                 ComicScreen() |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) { | ||||||
|  |                 CardPage(title = "Comic") { | ||||||
|  |                     ComicScreen(navController = navController) | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             composable(Screen.Transmission.route) { |             composable(Screen.Transmission.route, | ||||||
|                 // ComicScreen() |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) { | ||||||
|  |                 CardPage(title = "Tasks") { | ||||||
|  |                     TransmissionScreen(navigator = navController) | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|             composable(Screen.Me.route) { |  | ||||||
|                 MeScreen(); |             composable(Screen.Live.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) { | ||||||
|  |                 LiveScreen() | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             composable(Screen.Me.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) { | ||||||
|  |                 MeScreen() | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             composable( |             composable( | ||||||
|                 route = Screen.VideoPlayer.route, |                 route = Screen.VideoPlayer.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|                 arguments = listOf(navArgument("videoId") { type = NavType.StringType }) |                 arguments = listOf(navArgument("videoId") { type = NavType.StringType }) | ||||||
|             ) { |             ) { | ||||||
|                 backStackEntry -> |                 backStackEntry -> | ||||||
| @@ -142,6 +211,37 @@ fun AppNavigation() { | |||||||
|                     VideoPlayer(videoId = videoId, navController = navController) |                     VideoPlayer(videoId = videoId, navController = navController) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             composable( | ||||||
|  |                 route = Screen.ComicGrid.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 arguments = listOf(navArgument("comicId") { type = NavType.StringType }) | ||||||
|  |             ) { | ||||||
|  |                     backStackEntry -> | ||||||
|  |                 val comicId = backStackEntry.arguments?.getString("comicId") | ||||||
|  |                 if (comicId != null) { | ||||||
|  |                     ComicGridView(comicId = comicId, navController = navController) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             composable( | ||||||
|  |                 route = Screen.ComicPage.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 arguments = listOf(navArgument("comicId") { type = NavType.StringType }, navArgument("page") { type = NavType.StringType }) | ||||||
|  |             ) { | ||||||
|  |                     backStackEntry -> | ||||||
|  |                 val comicId = backStackEntry.arguments?.getString("comicId") | ||||||
|  |                 val page = backStackEntry.arguments?.getString("page") | ||||||
|  |                 if (comicId != null && page != null) { | ||||||
|  |                     ComicPageView(comicId = comicId, page = page, navController = navController) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -156,9 +256,10 @@ fun BottomNavigationBar(navController: NavController) { | |||||||
|         Screen.Video, |         Screen.Video, | ||||||
|         Screen.Comic, |         Screen.Comic, | ||||||
|         Screen.Transmission, |         Screen.Transmission, | ||||||
|  |         Screen.Live, | ||||||
|         Screen.Me |         Screen.Me | ||||||
|     ) else  listOf( |     ) else  listOf( | ||||||
|         Screen.Home, |         Screen.Video, | ||||||
|         Screen.Transmission, |         Screen.Transmission, | ||||||
|         Screen.Me |         Screen.Me | ||||||
|     ) |     ) | ||||||
| @@ -183,12 +284,47 @@ fun BottomNavigationBar(navController: NavController) { | |||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun CardPage( | ||||||
|  |     title: String, | ||||||
|  |     modifier: Modifier = Modifier, | ||||||
|  |     content: @Composable () -> Unit | ||||||
|  | ) { | ||||||
|  |     Box(Modifier.background(if (isSystemInDarkTheme()) { | ||||||
|  |         Color.Black | ||||||
|  |     } else { | ||||||
|  |         Color.White | ||||||
|  |     }).fillMaxSize()) | ||||||
|  |     { | ||||||
|  |         val colorScheme = MaterialTheme.colorScheme | ||||||
|  |         Card( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxSize() | ||||||
|  |                 .padding(6.dp), | ||||||
|  |             elevation = CardDefaults.cardElevation(4.dp), | ||||||
|  |             shape = RoundedCornerShape(12.dp), | ||||||
|  |             colors = CardDefaults.cardColors(containerColor = colorScheme.background) | ||||||
|  |         ) { | ||||||
|  |             Box( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxSize() | ||||||
|  |             ) { | ||||||
|  |                 content() | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| sealed class Screen(val route: String, val icon: ImageVector, val title: String) { | sealed class Screen(val route: String, val icon: ImageVector, val title: String) { | ||||||
|     data object Home : Screen("home_route", Icons.Filled.Home, "Home") |     data object Home : Screen("home_route", Icons.Filled.Home, "Home") | ||||||
|     data object Video : Screen("video_route", Icons.Filled.VideoLibrary, "Video") |     data object Video : Screen("video_route", Icons.Filled.VideoLibrary, "Video") | ||||||
|     data object Comic : Screen("comic_route", Icons.Filled.Image, "Comic") |     data object Comic : Screen("comic_route", Icons.Filled.Image, "Comic") | ||||||
|     data object Transmission : Screen("transmission_route", |     data object Transmission : Screen("transmission_route", | ||||||
|         Icons.AutoMirrored.Filled.CompareArrows, "Transmission") |         Icons.AutoMirrored.Filled.CompareArrows, "Transmission") | ||||||
|  |     data object Live : Screen("live_route", | ||||||
|  |         Icons.Filled.LiveTv, "Live") | ||||||
|     data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me") |     data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me") | ||||||
|     data object VideoPlayer : Screen("video_player_route/{videoId}", Icons.Filled.PlayArrow, "VideoPlayer") |     data object VideoPlayer : Screen("video_player_route/{videoId}", Icons.Filled.PlayArrow, "VideoPlayer") | ||||||
|  |     data object ComicGrid : Screen("comic_grid_route/{comicId}", Icons.Filled.PlayArrow, "ComicGrid") | ||||||
|  |     data object ComicPage : Screen("comic_page_route/{comicId}/{page}", Icons.Filled.PlayArrow, "ComicPage") | ||||||
| } | } | ||||||
| @@ -0,0 +1,74 @@ | |||||||
|  | package com.acitelight.aether.helper | ||||||
|  |  | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import java.text.Collator | ||||||
|  | import java.util.Locale | ||||||
|  |  | ||||||
|  | fun MutableList<Video>.insertInNaturalOrder(n: Video) { | ||||||
|  |     // Windows sorting is locale-sensitive. Use the default locale. | ||||||
|  |     val collator = Collator.getInstance(Locale.getDefault()) | ||||||
|  |  | ||||||
|  |     val naturalComparator = Comparator<String> { s1, s2 -> | ||||||
|  |         val naturalOrderComparator = fun(str1: String, str2: String): Int { | ||||||
|  |             // Function to compare segments (numeric vs. non-numeric) | ||||||
|  |             val compareSegments = fun(segment1: String, segment2: String, isNumeric: Boolean): Int { | ||||||
|  |                 return if (isNumeric) { | ||||||
|  |                     val num1 = segment1.toLongOrNull() ?: 0 | ||||||
|  |                     val num2 = segment2.toLongOrNull() ?: 0 | ||||||
|  |                     num1.compareTo(num2) | ||||||
|  |                 } else { | ||||||
|  |                     collator.compare(segment1, segment2) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // Regex to split string into numeric and non-numeric parts | ||||||
|  |             val regex = "(\\d+)|(\\D+)".toRegex() | ||||||
|  |             val matches1 = regex.findAll(str1).toList() | ||||||
|  |             val matches2 = regex.findAll(str2).toList() | ||||||
|  |  | ||||||
|  |             var i = 0 | ||||||
|  |             while (i < matches1.size && i < matches2.size) { | ||||||
|  |                 val match1 = matches1[i] | ||||||
|  |                 val match2 = matches2[i] | ||||||
|  |  | ||||||
|  |                 val isNumeric1 = match1.groupValues[1].isNotEmpty() | ||||||
|  |                 val isNumeric2 = match2.groupValues[1].isNotEmpty() | ||||||
|  |  | ||||||
|  |                 when { | ||||||
|  |                     isNumeric1 && isNumeric2 -> { | ||||||
|  |                         val result = compareSegments(match1.value, match2.value, true) | ||||||
|  |                         if (result != 0) return result | ||||||
|  |                     } | ||||||
|  |                     !isNumeric1 && !isNumeric2 -> { | ||||||
|  |                         val result = compareSegments(match1.value, match2.value, false) | ||||||
|  |                         if (result != 0) return result | ||||||
|  |                     } | ||||||
|  |                     isNumeric1 -> return -1 // Numeric part comes before non-numeric | ||||||
|  |                     isNumeric2 -> return 1 | ||||||
|  |                 } | ||||||
|  |                 i++ | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // If one string is a prefix of the other, the shorter one comes first | ||||||
|  |             return str1.length.compareTo(str2.length) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         naturalOrderComparator(s1, s2) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var inserted = false | ||||||
|  |  | ||||||
|  |     // Find the correct insertion point | ||||||
|  |     for (i in this.indices) { | ||||||
|  |         if (naturalComparator.compare(n.video.name, this[i].video.name) <= 0) { | ||||||
|  |             this.add(i, n) | ||||||
|  |             inserted = true | ||||||
|  |             break | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // If it's the largest, add to the end | ||||||
|  |     if (!inserted) { | ||||||
|  |         this.add(n) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,5 +1,8 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  |  | ||||||
|  | @Serializable | ||||||
| data class BookMark( | data class BookMark( | ||||||
|     val name: String, |     val name: String, | ||||||
|     val page: String |     val page: String | ||||||
|   | |||||||
| @@ -1,8 +1,81 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
| data class Comic( | import com.acitelight.aether.service.ApiClient | ||||||
|     val comic_name: String, |  | ||||||
|     val page_count: Int, | class Comic( | ||||||
|     val bookmarks: List<BookMark>, |     val comic: ComicResponse, | ||||||
|     val pages: List<String> |     val id: String | ||||||
| ) | ) | ||||||
|  | { | ||||||
|  |     fun getCover(api: ApiClient): String | ||||||
|  |     { | ||||||
|  |         if(id == "101") | ||||||
|  |             print("") | ||||||
|  |  | ||||||
|  |         if(comic.cover != "") | ||||||
|  |         { | ||||||
|  |             return "${api.getBase()}api/image/$id/${comic.cover}" | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return "${api.getBase()}api/image/$id/${comic.list[0]}" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getPage(pageNumber: Int, api: ApiClient): String | ||||||
|  |     { | ||||||
|  |         return "${api.getBase()}api/image/$id/${comic.list[pageNumber]}" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getPage(pageName: String, api: ApiClient): String? | ||||||
|  |     { | ||||||
|  |         val v = comic.list.indexOf(pageName) | ||||||
|  |         if(v >= 0) | ||||||
|  |         { | ||||||
|  |             return getPage(v, api) | ||||||
|  |         } | ||||||
|  |         return null | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getPageIndex(pageName: String): Int | ||||||
|  |     { | ||||||
|  |         return comic.list.indexOf(pageName) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getChapterLength(pageName: String): Int | ||||||
|  |     { | ||||||
|  |         var v = comic.list.indexOf(pageName) | ||||||
|  |         if(v >= 0) | ||||||
|  |         { | ||||||
|  |             var r = 1 | ||||||
|  |             v+=1 | ||||||
|  |             while(v < comic.list.size && !comic.bookmarks.any{ | ||||||
|  |                 x -> x.page == comic.list[v] | ||||||
|  |                 }){ | ||||||
|  |                 r++ | ||||||
|  |                 v+=1 | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             return r | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return -1 | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getPageChapterIndex(page: Int): Pair<BookMark, Int> | ||||||
|  |     { | ||||||
|  |         var p = page | ||||||
|  |         while(p >= 0 && !comic.bookmarks.any{ x -> x.page == comic.list[p] }) | ||||||
|  |         { | ||||||
|  |             p-- | ||||||
|  |         } | ||||||
|  |         if(p < 0) return Pair(BookMark(name="null", page=comic.list[0]), page + 1) | ||||||
|  |         for(i in comic.bookmarks) | ||||||
|  |         { | ||||||
|  |             if(i.page == comic.list[p]) | ||||||
|  |             { | ||||||
|  |                 return Pair(i, page - comic.list.indexOf(i.page) + 1) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return Pair(BookMark(name="null", page=comic.list[0]), page + 1) | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								app/src/main/java/com/acitelight/aether/model/ComicRecord.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,13 @@ | |||||||
|  | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import androidx.room.ColumnInfo | ||||||
|  | import androidx.room.Entity | ||||||
|  | import androidx.room.PrimaryKey | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Entity | ||||||
|  | data class ComicRecord( | ||||||
|  |     @PrimaryKey(autoGenerate = false) val id: Int = 0, | ||||||
|  |     @ColumnInfo(name = "name") val name: String, | ||||||
|  |     @ColumnInfo(name = "position") val position: Int | ||||||
|  | ) | ||||||
| @@ -0,0 +1,27 @@ | |||||||
|  | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import androidx.room.Dao | ||||||
|  | import androidx.room.Delete | ||||||
|  | import androidx.room.Insert | ||||||
|  | import androidx.room.OnConflictStrategy | ||||||
|  | import androidx.room.Query | ||||||
|  | import androidx.room.Update | ||||||
|  | import kotlinx.coroutines.flow.Flow | ||||||
|  |  | ||||||
|  | @Dao | ||||||
|  | interface ComicRecordDao { | ||||||
|  |     @Query("SELECT * FROM comicrecord") | ||||||
|  |     fun getAll(): Flow<List<ComicRecord>> | ||||||
|  |  | ||||||
|  |     @Insert(onConflict = OnConflictStrategy.REPLACE) | ||||||
|  |     suspend fun insert(rec: ComicRecord) | ||||||
|  |  | ||||||
|  |     @Update | ||||||
|  |     suspend fun update(rec: ComicRecord) | ||||||
|  |  | ||||||
|  |     @Delete | ||||||
|  |     suspend fun delete(rec: ComicRecord) | ||||||
|  |  | ||||||
|  |     @Query("SELECT * FROM comicrecord WHERE id = :id") | ||||||
|  |     suspend fun getById(id: Int): ComicRecord? | ||||||
|  | } | ||||||
| @@ -0,0 +1,29 @@ | |||||||
|  | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.room.Database | ||||||
|  | import androidx.room.Room | ||||||
|  | import androidx.room.RoomDatabase | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Database(entities = [ComicRecord::class], version = 1) | ||||||
|  | abstract class ComicRecordDatabase : RoomDatabase() { | ||||||
|  |     abstract fun userDao(): ComicRecordDao | ||||||
|  |  | ||||||
|  |     companion object { | ||||||
|  |         @Volatile | ||||||
|  |         private var INSTANCE: ComicRecordDatabase? = null | ||||||
|  |  | ||||||
|  |         fun getDatabase(context: Context): ComicRecordDatabase { | ||||||
|  |             return INSTANCE ?: synchronized(this) { | ||||||
|  |                 val instance = Room.databaseBuilder( | ||||||
|  |                     context.applicationContext, | ||||||
|  |                     ComicRecordDatabase::class.java, | ||||||
|  |                     "comicrecord_database" | ||||||
|  |                 ).build() | ||||||
|  |                 INSTANCE = instance | ||||||
|  |                 instance | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,14 @@ | |||||||
|  | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  |  | ||||||
|  | @Serializable | ||||||
|  | data class ComicResponse( | ||||||
|  |     val comic_name: String, | ||||||
|  |     val page_count: Int, | ||||||
|  |     val bookmarks: List<BookMark>, | ||||||
|  |     val list: List<String>, | ||||||
|  |     val tags: List<String>, | ||||||
|  |     val author: String, | ||||||
|  |     val cover: String | ||||||
|  | ) | ||||||
| @@ -1,5 +1,9 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Serializable | ||||||
| data class Comment( | data class Comment( | ||||||
|     val content: String, |     val content: String, | ||||||
|     val username: String, |     val username: String, | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
| data class KeyImage( | data class KeyImage( | ||||||
|  |     val name: String, | ||||||
|     val url: String, |     val url: String, | ||||||
|     val key: String |     val key: String | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -1,29 +1,64 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
| import com.acitelight.aether.service.ApiClient | import com.acitelight.aether.service.ApiClient | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
| import java.security.KeyPair | import java.security.KeyPair | ||||||
|  |  | ||||||
| class Video constructor( |  | ||||||
|  | @Serializable | ||||||
|  | class Video( | ||||||
|  |     val isLocal: Boolean, | ||||||
|  |     val localBase: String, | ||||||
|     val klass: String, |     val klass: String, | ||||||
|     val id: String, |     val id: String, | ||||||
|     val token: String, |  | ||||||
|     val video: VideoResponse |     val video: VideoResponse | ||||||
|     ){ | ) { | ||||||
|     fun getCover(): String |     fun getCover(api: ApiClient): String { | ||||||
|     { |         return if (isLocal) | ||||||
|         return "${ApiClient.base}api/video/$klass/$id/cover?token=$token" |             "$localBase/videos/$klass/$id/cover.jpg" | ||||||
|  |         else | ||||||
|  |             "${api.getBase()}api/video/$klass/$id/cover" | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun getVideo(): String |     fun getVideo(api: ApiClient): String { | ||||||
|     { |         return if (isLocal) | ||||||
|         return "${ApiClient.base}api/video/$klass/$id/av?token=$token" |             "$localBase/videos/$klass/$id/video.mp4" | ||||||
|  |         else | ||||||
|  |             "${api.getBase()}api/video/$klass/$id/av" | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun getGallery(): List<KeyImage> |     fun getSubtitle(api: ApiClient): String { | ||||||
|     { |         return if (isLocal) | ||||||
|         return video.gallery.map{ |             "$localBase/videos/$klass/$id/subtitle.vtt" | ||||||
|             KeyImage(url = "${ApiClient.base}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it") |         else | ||||||
|  |             "${api.getBase()}api/video/$klass/$id/subtitle" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getGallery(api: ApiClient): List<KeyImage> { | ||||||
|  |         return if (isLocal) | ||||||
|  |             video.gallery.map { | ||||||
|  |                 KeyImage( | ||||||
|  |                     name = it, | ||||||
|  |                     url = "$localBase/videos/$klass/$id/gallery/$it", | ||||||
|  |                     key = "$klass/$id/gallery/$it" | ||||||
|  |                 ) | ||||||
|  |             } else video.gallery.map { | ||||||
|  |             KeyImage( | ||||||
|  |                 name = it, | ||||||
|  |                 url = "${api.getBase()}api/video/$klass/$id/gallery/$it", | ||||||
|  |                 key = "$klass/$id/gallery/$it" | ||||||
|  |             ) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun toLocal(localBase: String): Video | ||||||
|  |     { | ||||||
|  |         return Video( | ||||||
|  |             isLocal = true, | ||||||
|  |             localBase = localBase, | ||||||
|  |             klass = klass, | ||||||
|  |             id = id, | ||||||
|  |             video = video | ||||||
|  |         ) | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -0,0 +1,31 @@ | |||||||
|  | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableStateOf | ||||||
|  | import androidx.compose.runtime.setValue | ||||||
|  | import com.tonyodev.fetch2.Status | ||||||
|  |  | ||||||
|  | class VideoDownloadItemState( | ||||||
|  |     val id: Int, | ||||||
|  |     fileName: String, | ||||||
|  |     filePath: String, | ||||||
|  |     url: String, | ||||||
|  |     progress: Int, | ||||||
|  |     status: Status, | ||||||
|  |     downloadedBytes: Long, | ||||||
|  |     totalBytes: Long, | ||||||
|  |     klass: String, | ||||||
|  |     vid: String, | ||||||
|  |     val type: String | ||||||
|  | ) { | ||||||
|  |     var fileName by mutableStateOf(fileName) | ||||||
|  |     var filePath by mutableStateOf(filePath) | ||||||
|  |     var url by mutableStateOf(url) | ||||||
|  |     var progress by mutableStateOf(progress) | ||||||
|  |     var status by mutableStateOf(status) | ||||||
|  |     var downloadedBytes by mutableStateOf(downloadedBytes) | ||||||
|  |     var totalBytes by mutableStateOf(totalBytes) | ||||||
|  |  | ||||||
|  |     var klass by mutableStateOf(klass) | ||||||
|  |     var vid by mutableStateOf(vid) | ||||||
|  | } | ||||||
							
								
								
									
										14
									
								
								app/src/main/java/com/acitelight/aether/model/VideoRecord.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,14 @@ | |||||||
|  | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import androidx.room.ColumnInfo | ||||||
|  | import androidx.room.Entity | ||||||
|  | import androidx.room.PrimaryKey | ||||||
|  |  | ||||||
|  | @Entity | ||||||
|  | data class VideoRecord ( | ||||||
|  |     @PrimaryKey(autoGenerate = false) val id: String = "", | ||||||
|  |     @ColumnInfo(name = "name") val klass: String = "", | ||||||
|  |     @ColumnInfo(name = "position") val position: Long, | ||||||
|  |     @ColumnInfo(name = "time") val time: Long, | ||||||
|  |     @ColumnInfo(name = "group") val group: String | ||||||
|  | ) | ||||||
| @@ -0,0 +1,27 @@ | |||||||
|  | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import androidx.room.Dao | ||||||
|  | import androidx.room.Delete | ||||||
|  | import androidx.room.Insert | ||||||
|  | import androidx.room.OnConflictStrategy | ||||||
|  | import androidx.room.Query | ||||||
|  | import androidx.room.Update | ||||||
|  | import kotlinx.coroutines.flow.Flow | ||||||
|  |  | ||||||
|  | @Dao | ||||||
|  | interface VideoRecordDao { | ||||||
|  |     @Query("SELECT * FROM videorecord") | ||||||
|  |     fun getAll(): Flow<List<VideoRecord>> | ||||||
|  |  | ||||||
|  |     @Insert(onConflict = OnConflictStrategy.REPLACE) | ||||||
|  |     suspend fun insert(rec: VideoRecord) | ||||||
|  |  | ||||||
|  |     @Update | ||||||
|  |     suspend fun update(rec: VideoRecord) | ||||||
|  |  | ||||||
|  |     @Delete | ||||||
|  |     suspend fun delete(rec: VideoRecord) | ||||||
|  |  | ||||||
|  |     @Query("SELECT * FROM videorecord WHERE id = :id and name = :klass") | ||||||
|  |     suspend fun get(id: String, klass: String): VideoRecord? | ||||||
|  | } | ||||||
| @@ -0,0 +1,28 @@ | |||||||
|  | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.room.Database | ||||||
|  | import androidx.room.Room | ||||||
|  | import androidx.room.RoomDatabase | ||||||
|  |  | ||||||
|  | @Database(entities = [VideoRecord::class], version = 1) | ||||||
|  | abstract class VideoRecordDatabase : RoomDatabase()  { | ||||||
|  |     abstract fun userDao(): VideoRecordDao | ||||||
|  |  | ||||||
|  |     companion object { | ||||||
|  |         @Volatile | ||||||
|  |         private var INSTANCE: VideoRecordDatabase? = null | ||||||
|  |  | ||||||
|  |         fun getDatabase(context: Context): VideoRecordDatabase { | ||||||
|  |             return INSTANCE ?: synchronized(this) { | ||||||
|  |                 val instance = Room.databaseBuilder( | ||||||
|  |                     context.applicationContext, | ||||||
|  |                     VideoRecordDatabase::class.java, | ||||||
|  |                     "videorecords_database" | ||||||
|  |                 ).build() | ||||||
|  |                 INSTANCE = instance | ||||||
|  |                 instance | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,5 +1,8 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  |  | ||||||
|  | @Serializable | ||||||
| data class VideoResponse( | data class VideoResponse( | ||||||
|     val name: String, |     val name: String, | ||||||
|     val duration: Long, |     val duration: Long, | ||||||
| @@ -7,5 +10,6 @@ data class VideoResponse( | |||||||
|     val comment: List<Comment>, |     val comment: List<Comment>, | ||||||
|     val star: Boolean, |     val star: Boolean, | ||||||
|     val like: Int, |     val like: Int, | ||||||
|     val author: String |     val author: String, | ||||||
|  |     val group: String? | ||||||
| ) | ) | ||||||
|   | |||||||
							
								
								
									
										368
									
								
								app/src/main/java/com/acitelight/aether/service/AbyssStream.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,368 @@ | |||||||
|  | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import kotlinx.coroutines.* | ||||||
|  | import java.io.InputStream | ||||||
|  | import java.io.OutputStream | ||||||
|  |  | ||||||
|  | import java.net.Socket | ||||||
|  | import java.nio.ByteBuffer | ||||||
|  | import java.security.SecureRandom | ||||||
|  | import java.util.ArrayDeque | ||||||
|  |  | ||||||
|  | import org.bouncycastle.math.ec.rfc7748.X25519 | ||||||
|  | import org.bouncycastle.crypto.generators.HKDFBytesGenerator | ||||||
|  | import org.bouncycastle.crypto.digests.SHA256Digest | ||||||
|  | import org.bouncycastle.crypto.params.HKDFParameters | ||||||
|  | import org.bouncycastle.crypto.params.KeyParameter | ||||||
|  | import org.bouncycastle.crypto.params.AEADParameters | ||||||
|  | import org.bouncycastle.crypto.modes.ChaCha20Poly1305 | ||||||
|  | import java.io.EOFException | ||||||
|  | import java.util.concurrent.atomic.AtomicLong | ||||||
|  |  | ||||||
|  | class AbyssStream private constructor( | ||||||
|  |     private val socket: Socket, | ||||||
|  |     private val input: InputStream, | ||||||
|  |     private val output: OutputStream, | ||||||
|  |     private val aeadKey: ByteArray, | ||||||
|  |     private val sendSalt: ByteArray, | ||||||
|  |     private val recvSalt: ByteArray | ||||||
|  | ) { | ||||||
|  |     companion object { | ||||||
|  |         private const val PUBLIC_KEY_LEN = 32 | ||||||
|  |         private const val AEAD_KEY_LEN = 32 | ||||||
|  |         private const val NONCE_SALT_LEN = 4 | ||||||
|  |         private const val AEAD_TAG_LEN = 16 | ||||||
|  |         private const val NONCE_LEN = 12 | ||||||
|  |         private const val MAX_PLAINTEXT_FRAME = 64 * 1024 | ||||||
|  |  | ||||||
|  |         private val secureRandom = SecureRandom() | ||||||
|  |  | ||||||
|  |         /** | ||||||
|  |          * Create and perform handshake on an already-connected socket. | ||||||
|  |          * If privateKeyRaw is provided, it must be 32 bytes. | ||||||
|  |          */ | ||||||
|  |         suspend fun create(authManager: AuthManager, socket: Socket, privateKeyRaw: ByteArray? = null): AbyssStream = withContext(Dispatchers.IO) { | ||||||
|  |             if (!socket.isConnected) throw IllegalArgumentException("socket is not connected") | ||||||
|  |             val inStream = socket.getInputStream() | ||||||
|  |             val outStream = socket.getOutputStream() | ||||||
|  |  | ||||||
|  |             // 1) keypair (raw) | ||||||
|  |             val localPriv = ByteArray(PUBLIC_KEY_LEN) | ||||||
|  |             if (privateKeyRaw != null) { | ||||||
|  |                 if (privateKeyRaw.size != PUBLIC_KEY_LEN) { | ||||||
|  |                     throw IllegalArgumentException("privateKeyRaw must be $PUBLIC_KEY_LEN bytes") | ||||||
|  |                 } | ||||||
|  |                 System.arraycopy(privateKeyRaw, 0, localPriv, 0, PUBLIC_KEY_LEN) | ||||||
|  |             } else { | ||||||
|  |                 X25519.generatePrivateKey(secureRandom, localPriv) | ||||||
|  |             } | ||||||
|  |             val localPub = ByteArray(PUBLIC_KEY_LEN) | ||||||
|  |             X25519.scalarMultBase(localPriv, 0, localPub, 0) | ||||||
|  |  | ||||||
|  |             // 2) exchange raw public keys (exact 32 bytes each) using blocking IO | ||||||
|  |             writeExact(outStream, localPub, 0, PUBLIC_KEY_LEN) | ||||||
|  |             val remotePub = ByteArray(PUBLIC_KEY_LEN) | ||||||
|  |             readExact(inStream, remotePub, 0, PUBLIC_KEY_LEN) | ||||||
|  |  | ||||||
|  |             val ch = ByteArray(32) | ||||||
|  |             readExact(inStream, ch, 0, 32) | ||||||
|  |             val signed = authManager.signChallengeByte(localPriv, ch) | ||||||
|  |             writeExact(outStream, signed, 0, signed.size) | ||||||
|  |             readExact(inStream, ch, 0, 16) | ||||||
|  |  | ||||||
|  |             // 3) compute shared secret: X25519.scalarMult(private, remotePublic) | ||||||
|  |             val shared = ByteArray(PUBLIC_KEY_LEN) | ||||||
|  |             X25519.scalarMult(localPriv, 0, remotePub, 0, shared, 0) | ||||||
|  |  | ||||||
|  |             // 4) HKDF-SHA256 -> AEAD key + saltA + saltB | ||||||
|  |             val hkdf = HKDFBytesGenerator(SHA256Digest()) | ||||||
|  |             // AEAD key | ||||||
|  |             hkdf.init(HKDFParameters(shared, null, "Abyss-AEAD-Key".toByteArray(Charsets.US_ASCII))) | ||||||
|  |             val aeadKey = ByteArray(AEAD_KEY_LEN) | ||||||
|  |             hkdf.generateBytes(aeadKey, 0, AEAD_KEY_LEN) | ||||||
|  |  | ||||||
|  |             // salt A | ||||||
|  |             hkdf.init(HKDFParameters(shared, null, "Abyss-Nonce-Salt-A".toByteArray(Charsets.US_ASCII))) | ||||||
|  |             val saltA = ByteArray(NONCE_SALT_LEN) | ||||||
|  |             hkdf.generateBytes(saltA, 0, NONCE_SALT_LEN) | ||||||
|  |  | ||||||
|  |             // salt B | ||||||
|  |             hkdf.init(HKDFParameters(shared, null, "Abyss-Nonce-Salt-B".toByteArray(Charsets.US_ASCII))) | ||||||
|  |             val saltB = ByteArray(NONCE_SALT_LEN) | ||||||
|  |             hkdf.generateBytes(saltB, 0, NONCE_SALT_LEN) | ||||||
|  |  | ||||||
|  |             // Deterministic assignment by lexicographic comparison | ||||||
|  |             val cmp = lexicographicCompare(localPub, remotePub) | ||||||
|  |             val sendSalt: ByteArray | ||||||
|  |             val recvSalt: ByteArray | ||||||
|  |             if (cmp < 0) { | ||||||
|  |                 sendSalt = saltA | ||||||
|  |                 recvSalt = saltB | ||||||
|  |             } else if (cmp > 0) { | ||||||
|  |                 sendSalt = saltB | ||||||
|  |                 recvSalt = saltA | ||||||
|  |             } else { | ||||||
|  |                 // extremely unlikely | ||||||
|  |                 sendSalt = saltA | ||||||
|  |                 recvSalt = saltB | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             // zero sensitive buffers | ||||||
|  |             localPriv.fill(0) | ||||||
|  |             localPub.fill(0) | ||||||
|  |             remotePub.fill(0) | ||||||
|  |             shared.fill(0) | ||||||
|  |             // keep aeadKey, sendSalt, recvSalt | ||||||
|  |  | ||||||
|  |             return@withContext AbyssStream(socket, inStream, outStream, aeadKey, sendSalt, recvSalt) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private fun lexicographicCompare(a: ByteArray, b: ByteArray): Int { | ||||||
|  |             val min = kotlin.math.min(a.size, b.size) | ||||||
|  |             for (i in 0 until min) { | ||||||
|  |                 val av = a[i].toInt() and 0xff | ||||||
|  |                 val bv = b[i].toInt() and 0xff | ||||||
|  |                 if (av < bv) return -1 | ||||||
|  |                 if (av > bv) return 1 | ||||||
|  |             } | ||||||
|  |             if (a.size < b.size) return -1 | ||||||
|  |             if (a.size > b.size) return 1 | ||||||
|  |             return 0 | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private fun readExact(input: InputStream, buffer: ByteArray, offset: Int, count: Int) { | ||||||
|  |             var read = 0 | ||||||
|  |             while (read < count) { | ||||||
|  |                 val n = input.read(buffer, offset + read, count - read) | ||||||
|  |                 if (n == -1) { | ||||||
|  |                     if (read == 0) throw EOFException("Remote closed connection while reading") | ||||||
|  |                     else throw EOFException("Remote closed connection unexpectedly during read") | ||||||
|  |                 } | ||||||
|  |                 read += n | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private fun writeExact(output: OutputStream, buffer: ByteArray, offset: Int, count: Int) { | ||||||
|  |             output.write(buffer, offset, count) | ||||||
|  |             output.flush() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // internal state | ||||||
|  |     private val sendCounter = AtomicLong(0L) | ||||||
|  |     private val recvCounter = AtomicLong(0L) | ||||||
|  |     private val sendLock = Any() | ||||||
|  |     private val aeadLock = Any() | ||||||
|  |  | ||||||
|  |     // leftover read queue | ||||||
|  |     private val leftoverQueue = ArrayDeque<ByteArray>() | ||||||
|  |     private var currentLeftover: ByteArray? = null | ||||||
|  |     private var currentLeftoverOffset = 0 | ||||||
|  |  | ||||||
|  |     @Volatile | ||||||
|  |     private var closed = false | ||||||
|  |  | ||||||
|  |     // ---- high-level read/write APIs (suspendable) ---- | ||||||
|  |  | ||||||
|  |     suspend fun read(buffer: ByteArray, offset: Int, count: Int): Int = withContext(Dispatchers.IO) { | ||||||
|  |         if (closed) throw IllegalStateException("AbyssStream closed") | ||||||
|  |         if (buffer.size < offset + count) throw IndexOutOfBoundsException() | ||||||
|  |         // serve leftover first | ||||||
|  |         if (ensureCurrentLeftover()) { | ||||||
|  |             val seg = currentLeftover!! | ||||||
|  |             val avail = seg.size - currentLeftoverOffset | ||||||
|  |             val toCopy = kotlin.math.min(avail, count) | ||||||
|  |             System.arraycopy(seg, currentLeftoverOffset, buffer, offset, toCopy) | ||||||
|  |             currentLeftoverOffset += toCopy | ||||||
|  |             if (currentLeftoverOffset >= seg.size) { | ||||||
|  |                 currentLeftover = null | ||||||
|  |                 currentLeftoverOffset = 0 | ||||||
|  |             } | ||||||
|  |             return@withContext toCopy | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // read one frame and decrypt | ||||||
|  |         val plaintext = readOneFrameAndDecrypt() | ||||||
|  |         if (plaintext == null || plaintext.isEmpty()) { | ||||||
|  |             // EOF | ||||||
|  |             return@withContext 0 | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return@withContext if (plaintext.size <= count) { | ||||||
|  |             System.arraycopy(plaintext, 0, buffer, offset, plaintext.size) | ||||||
|  |             plaintext.size | ||||||
|  |         } else { | ||||||
|  |             System.arraycopy(plaintext, 0, buffer, offset, count) | ||||||
|  |             val leftoverLen = plaintext.size - count | ||||||
|  |             val leftover = ByteArray(leftoverLen) | ||||||
|  |             System.arraycopy(plaintext, count, leftover, 0, leftoverLen) | ||||||
|  |             synchronized(leftoverQueue) { leftoverQueue.addLast(leftover) } | ||||||
|  |             count | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun ensureCurrentLeftover(): Boolean { | ||||||
|  |         if (currentLeftover != null && currentLeftoverOffset < currentLeftover!!.size) return true | ||||||
|  |         synchronized(leftoverQueue) { | ||||||
|  |             val next = leftoverQueue.pollFirst() | ||||||
|  |             if (next != null) { | ||||||
|  |                 currentLeftover = next | ||||||
|  |                 currentLeftoverOffset = 0 | ||||||
|  |                 return true | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         return false | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun readOneFrameAndDecrypt(): ByteArray? { | ||||||
|  |         // read 4-byte header | ||||||
|  |         val header = ByteArray(4) | ||||||
|  |         try { | ||||||
|  |             readExact(input, header, 0, 4) | ||||||
|  |         } catch (_: EOFException) { | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|  |         val payloadLen = ByteBuffer.wrap(header).int and 0xffffffff.toInt() | ||||||
|  |         if (payloadLen > MAX_PLAINTEXT_FRAME + AEAD_TAG_LEN) throw IllegalStateException("payload too big") | ||||||
|  |         if (payloadLen < AEAD_TAG_LEN) throw IllegalStateException("payload too small") | ||||||
|  |  | ||||||
|  |         val payload = ByteArray(payloadLen) | ||||||
|  |         readExact(input, payload, 0, payloadLen) | ||||||
|  |  | ||||||
|  |         val ciphertextLen = payloadLen - AEAD_TAG_LEN | ||||||
|  |         val ciphertext = ByteArray(ciphertextLen) | ||||||
|  |         val tag = ByteArray(AEAD_TAG_LEN) | ||||||
|  |         if (ciphertextLen > 0) System.arraycopy(payload, 0, ciphertext, 0, ciphertextLen) | ||||||
|  |         System.arraycopy(payload, ciphertextLen, tag, 0, AEAD_TAG_LEN) | ||||||
|  |  | ||||||
|  |         val remoteCounterValue = recvCounter.getAndIncrement() | ||||||
|  |  | ||||||
|  |         val nonce = ByteArray(NONCE_LEN) | ||||||
|  |         System.arraycopy(recvSalt, 0, nonce, 0, NONCE_SALT_LEN) | ||||||
|  |         // write 8-byte big-endian counter at nonce[4..11] | ||||||
|  |         val bb = ByteBuffer.wrap(nonce, NONCE_SALT_LEN, 8) | ||||||
|  |         bb.putLong(remoteCounterValue) | ||||||
|  |  | ||||||
|  |         val plaintext = try { | ||||||
|  |             aeadDecrypt(nonce, ciphertext, tag) | ||||||
|  |         } catch (ex: Exception) { | ||||||
|  |             close() | ||||||
|  |             throw SecurityException("AEAD authentication failed; connection closed.", ex) | ||||||
|  |         } finally { | ||||||
|  |             nonce.fill(0) | ||||||
|  |             payload.fill(0) | ||||||
|  |             ciphertext.fill(0) | ||||||
|  |             tag.fill(0) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return plaintext | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun write(buffer: ByteArray, offset: Int, count: Int) = withContext(Dispatchers.IO) { | ||||||
|  |         if (closed) throw IllegalStateException("AbyssStream closed") | ||||||
|  |         if (buffer.size < offset + count) throw IndexOutOfBoundsException() | ||||||
|  |         var remaining = count | ||||||
|  |         var idx = offset | ||||||
|  |         while (remaining > 0) { | ||||||
|  |             val chunk = kotlin.math.min(remaining, MAX_PLAINTEXT_FRAME) | ||||||
|  |             val plaintext = buffer.copyOfRange(idx, idx + chunk) | ||||||
|  |             sendPlaintextChunk(plaintext) | ||||||
|  |             idx += chunk | ||||||
|  |             remaining -= chunk | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun sendPlaintextChunk(plaintext: ByteArray) { | ||||||
|  |         if (closed) throw IllegalStateException("AbyssStream closed") | ||||||
|  |  | ||||||
|  |         val nonce = ByteArray(NONCE_LEN) | ||||||
|  |         val ciphertextAndTag: ByteArray | ||||||
|  |         val counterValue: Long | ||||||
|  |         synchronized(sendLock) { | ||||||
|  |             counterValue = sendCounter.getAndIncrement() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         System.arraycopy(sendSalt, 0, nonce, 0, NONCE_SALT_LEN) | ||||||
|  |         ByteBuffer.wrap(nonce, NONCE_SALT_LEN, 8).putLong(counterValue) | ||||||
|  |  | ||||||
|  |         try { | ||||||
|  |             ciphertextAndTag = aeadEncrypt(nonce, plaintext) | ||||||
|  |         } finally { | ||||||
|  |             nonce.fill(0) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val payloadLen = ciphertextAndTag.size | ||||||
|  |         // header + ciphertextAndTag 一次性合并 | ||||||
|  |         val packet = ByteBuffer.allocate(4 + payloadLen) | ||||||
|  |             .putInt(payloadLen) | ||||||
|  |             .put(ciphertextAndTag) | ||||||
|  |             .array() | ||||||
|  |  | ||||||
|  |         try { | ||||||
|  |             synchronized(output) { | ||||||
|  |                 output.write(packet) | ||||||
|  |                 output.flush() | ||||||
|  |             } | ||||||
|  |         } finally { | ||||||
|  |             // clear sensitive | ||||||
|  |             ciphertextAndTag.fill(0) | ||||||
|  |             plaintext.fill(0) | ||||||
|  |             packet.fill(0) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // ---- AEAD helpers using BouncyCastle lightweight API ---- | ||||||
|  |     // ChaCha20-Poly1305 with 12-byte nonce. BouncyCastle ChaCha20Poly1305 produces ciphertext+tag. | ||||||
|  |  | ||||||
|  |     private fun aeadEncrypt(nonce: ByteArray, plaintext: ByteArray): ByteArray { | ||||||
|  |         synchronized(aeadLock) { | ||||||
|  |             val cipher = ChaCha20Poly1305() | ||||||
|  |             val params = AEADParameters(KeyParameter(aeadKey), AEAD_TAG_LEN * 8, nonce, null) | ||||||
|  |             cipher.init(true, params) | ||||||
|  |             val outBuf = ByteArray(cipher.getOutputSize(plaintext.size)) | ||||||
|  |             var len = cipher.processBytes(plaintext, 0, plaintext.size, outBuf, 0) | ||||||
|  |             len += cipher.doFinal(outBuf, len) | ||||||
|  |             if (len != outBuf.size) return outBuf.copyOf(len) | ||||||
|  |             return outBuf | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun aeadDecrypt(nonce: ByteArray, ciphertext: ByteArray, tag: ByteArray): ByteArray { | ||||||
|  |         synchronized(aeadLock) { | ||||||
|  |             val cipher = ChaCha20Poly1305() | ||||||
|  |             val params = AEADParameters(KeyParameter(aeadKey), AEAD_TAG_LEN * 8, nonce, null) | ||||||
|  |             cipher.init(false, params) | ||||||
|  |             // input is ciphertext||tag | ||||||
|  |             val input = ByteArray(ciphertext.size + tag.size) | ||||||
|  |             if (ciphertext.isNotEmpty()) System.arraycopy(ciphertext, 0, input, 0, ciphertext.size) | ||||||
|  |             System.arraycopy(tag, 0, input, ciphertext.size, tag.size) | ||||||
|  |             val outBuf = ByteArray(cipher.getOutputSize(input.size)) | ||||||
|  |             var len = cipher.processBytes(input, 0, input.size, outBuf, 0) | ||||||
|  |             try { | ||||||
|  |                 len += cipher.doFinal(outBuf, len) | ||||||
|  |             } catch (ex: Exception) { | ||||||
|  |                 // authentication failure or other | ||||||
|  |                 throw ex | ||||||
|  |             } | ||||||
|  |             return if (len != outBuf.size) outBuf.copyOf(len) else outBuf | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // ---- utility / lifecycle ---- | ||||||
|  |  | ||||||
|  |     fun close() { | ||||||
|  |         if (!closed) { | ||||||
|  |             closed = true | ||||||
|  |             try { socket.close() } catch (_: Exception) {} | ||||||
|  |             // clear secrets | ||||||
|  |             aeadKey.fill(0) | ||||||
|  |             sendSalt.fill(0) | ||||||
|  |             recvSalt.fill(0) | ||||||
|  |             synchronized(leftoverQueue) { | ||||||
|  |                 leftoverQueue.forEach { it.fill(0) } | ||||||
|  |                 leftoverQueue.clear() | ||||||
|  |             } | ||||||
|  |             currentLeftover = null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,121 @@ | |||||||
|  | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import kotlinx.coroutines.* | ||||||
|  | import kotlinx.coroutines.flow.first | ||||||
|  | import kotlinx.coroutines.selects.select | ||||||
|  | import java.io.InputStream | ||||||
|  | import java.io.OutputStream | ||||||
|  | import java.net.InetAddress | ||||||
|  | import java.net.ServerSocket | ||||||
|  | import java.net.Socket | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  | import kotlin.coroutines.CoroutineContext | ||||||
|  |  | ||||||
|  | @Singleton | ||||||
|  | class AbyssTunnelProxy @Inject constructor( | ||||||
|  |     private val settingsDataStoreManager: SettingsDataStoreManager, | ||||||
|  |     private val authManager: AuthManager | ||||||
|  | ) { | ||||||
|  |     private val coroutineContext: CoroutineContext = Dispatchers.IO | ||||||
|  |     private var serverHost: String = "" | ||||||
|  |     private var serverPort: Int = 0 | ||||||
|  |  | ||||||
|  |     fun config(host: String, port: Int) | ||||||
|  |     { | ||||||
|  |         serverHost = host | ||||||
|  |         serverPort = port | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private val listenAddress = InetAddress.getLoopbackAddress() | ||||||
|  |     private val listenPort = 4095 | ||||||
|  |     private var serverSocket: ServerSocket? = null | ||||||
|  |     private val scope = CoroutineScope(SupervisorJob() + coroutineContext) | ||||||
|  |  | ||||||
|  |     fun start() { | ||||||
|  |         serverSocket = ServerSocket(listenPort, 50, listenAddress) | ||||||
|  |         // accept loop | ||||||
|  |         scope.launch { | ||||||
|  |             val srv = serverSocket ?: return@launch | ||||||
|  |             try { | ||||||
|  |                 while (true) { | ||||||
|  |                     val client = srv.accept() | ||||||
|  |  | ||||||
|  |                     if(serverHost.isEmpty()) | ||||||
|  |                         continue | ||||||
|  |  | ||||||
|  |                     launch { | ||||||
|  |                         try { handleLocalConnection(client) } | ||||||
|  |                         catch (_: Exception) { /* ignore */ } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } catch (ex: Exception) { | ||||||
|  |                 println(ex.message) | ||||||
|  |                 // server stopped or fatal error | ||||||
|  |             } finally { | ||||||
|  |                 stop() | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun stop() { | ||||||
|  |         try { serverSocket?.close() } catch (_: Exception) {} | ||||||
|  |         scope.cancel() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private suspend fun handleLocalConnection(localSocket: Socket) = withContext(coroutineContext) { | ||||||
|  |         val localIn = localSocket.getInputStream() | ||||||
|  |         val localOut = localSocket.getOutputStream() | ||||||
|  |         var abyssSocket: Socket? = null | ||||||
|  |         var abyssStream: AbyssStream? = null | ||||||
|  |         try { | ||||||
|  |             abyssSocket = Socket(serverHost, serverPort) | ||||||
|  |             abyssStream = AbyssStream.create(authManager, abyssSocket, authManager.db64(settingsDataStoreManager.privateKeyFlow.first())) | ||||||
|  |  | ||||||
|  |             // concurrently copy in both directions | ||||||
|  |             val job1 = launch { copyExactSuspend(localIn, abyssStream) }   // local -> abyss | ||||||
|  |             val job2 = launch { copyFromAbyssToLocal(abyssStream, localOut) } // abyss -> local | ||||||
|  |  | ||||||
|  |             // wait for either direction to finish | ||||||
|  |             select { | ||||||
|  |                 job1.onJoin { /* completed */ } | ||||||
|  |                 job2.onJoin { /* completed */ } | ||||||
|  |             } | ||||||
|  |             // cancel other | ||||||
|  |             job1.cancel() | ||||||
|  |             job2.cancel() | ||||||
|  |         } catch (ex: Exception) | ||||||
|  |         { | ||||||
|  |             println(ex.message) | ||||||
|  |             // log or ignore; we close sockets below | ||||||
|  |         } finally { | ||||||
|  |             try { localSocket.close() } catch (_: Exception) {} | ||||||
|  |             try { abyssStream?.close() } catch (_: Exception) {} | ||||||
|  |             try { abyssSocket?.close() } catch (_: Exception) {} | ||||||
|  |         } | ||||||
|  |         return@withContext | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Copy from local InputStream into AbyssStream.write in frames. | ||||||
|  |     private suspend fun copyExactSuspend(localIn: InputStream, abyss: AbyssStream) = withContext(coroutineContext) { | ||||||
|  |         val buffer = ByteArray(64 * 1024) | ||||||
|  |         while (true) { | ||||||
|  |             val read = localIn.read(buffer) | ||||||
|  |             if (read <= 0) | ||||||
|  |                 break | ||||||
|  |  | ||||||
|  |             abyss.write(buffer, 0, read) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Copy from AbyssStream (read frames/decrypt) to local OutputStream | ||||||
|  |     private suspend fun copyFromAbyssToLocal(abyss: AbyssStream, localOut: OutputStream) = withContext(coroutineContext) { | ||||||
|  |         val buffer = ByteArray(64 * 1024) | ||||||
|  |         while (true) { | ||||||
|  |             val n = abyss.read(buffer, 0, buffer.size) | ||||||
|  |             if (n <= 0) | ||||||
|  |                 break | ||||||
|  |             localOut.write(buffer, 0, n) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -2,33 +2,70 @@ | |||||||
| package com.acitelight.aether.service | package com.acitelight.aether.service | ||||||
|  |  | ||||||
| import android.content.Context | import android.content.Context | ||||||
|  | import android.util.Log | ||||||
|  | import androidx.core.net.toUri | ||||||
|  | import com.acitelight.aether.AetherApp | ||||||
|  | import com.franmontiel.persistentcookiejar.PersistentCookieJar | ||||||
|  | import com.franmontiel.persistentcookiejar.cache.SetCookieCache | ||||||
|  | import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor | ||||||
| import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||||
| import okhttp3.CertificatePinner | import okhttp3.ConnectionSpec | ||||||
|  | import okhttp3.Cookie | ||||||
|  | import okhttp3.CookieJar | ||||||
|  | import okhttp3.EventListener | ||||||
| import okhttp3.HttpUrl | import okhttp3.HttpUrl | ||||||
| import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | ||||||
|  | import okhttp3.JavaNetCookieJar | ||||||
| import okhttp3.MediaType.Companion.toMediaType | import okhttp3.MediaType.Companion.toMediaType | ||||||
| import okhttp3.OkHttpClient | import okhttp3.OkHttpClient | ||||||
| import retrofit2.Retrofit | import retrofit2.Retrofit | ||||||
| import retrofit2.converter.gson.GsonConverterFactory | import retrofit2.converter.gson.GsonConverterFactory | ||||||
| import java.io.ByteArrayInputStream | import java.io.ByteArrayInputStream | ||||||
|  | import java.net.CookieManager | ||||||
|  | import java.net.InetAddress | ||||||
|  | import java.net.InetSocketAddress | ||||||
|  | import java.net.Proxy | ||||||
| import java.security.KeyStore | import java.security.KeyStore | ||||||
| import java.security.cert.Certificate | import java.security.cert.CertificateException | ||||||
| import java.security.cert.CertificateFactory | import java.security.cert.CertificateFactory | ||||||
| import java.security.cert.X509Certificate | import java.security.cert.X509Certificate | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
| import javax.net.ssl.SSLContext | import javax.net.ssl.SSLContext | ||||||
| import javax.net.ssl.TrustManagerFactory | import javax.net.ssl.TrustManagerFactory | ||||||
| import javax.net.ssl.X509TrustManager | import javax.net.ssl.X509TrustManager | ||||||
|  |  | ||||||
| object ApiClient { | @Singleton | ||||||
|     var base: String = "" | class ApiClient @Inject constructor( | ||||||
|     var domain: String = "" |     @ApplicationContext private val context: Context, | ||||||
|     var cert: String = "" | ) { | ||||||
|  |     fun getBase(): String{ | ||||||
|  |         return replaceAbyssProtocol(base) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getDomain(): String = domain | ||||||
|  |  | ||||||
|  |     private var base: String = "" | ||||||
|  |     private var domain: String = "" | ||||||
|  |     private var cert: String = "" | ||||||
|     private val json = Json { |     private val json = Json { | ||||||
|         ignoreUnknownKeys = true |         ignoreUnknownKeys = true | ||||||
|     } |     } | ||||||
|  |     private fun replaceAbyssProtocol(uri: String): String { | ||||||
|     fun loadCertificateFromString(pemString: String): X509Certificate { |         return uri.replaceFirst("^abyss://".toRegex(), "https://") | ||||||
|  |     } | ||||||
|  |     private val dnsEventListener = object : EventListener() { | ||||||
|  |         override fun dnsEnd(call: okhttp3.Call, domainName: String, inetAddressList: List<InetAddress>) { | ||||||
|  |             super.dnsEnd(call, domainName, inetAddressList) | ||||||
|  |             val ipAddresses = inetAddressList.joinToString(", ") { it.hostAddress ?: "" } | ||||||
|  |             Log.d("OkHttp_DNS", "Domain '$domainName' resolved to IPs: [$ipAddresses]") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |     private fun loadCertificateFromString(pemString: String): X509Certificate { | ||||||
|         val certificateFactory = CertificateFactory.getInstance("X.509") |         val certificateFactory = CertificateFactory.getInstance("X.509") | ||||||
|         val decodedPem = pemString |         val decodedPem = pemString | ||||||
|             .replace("-----BEGIN CERTIFICATE-----", "") |             .replace("-----BEGIN CERTIFICATE-----", "") | ||||||
| @@ -41,66 +78,191 @@ object ApiClient { | |||||||
|             return certificateFactory.generateCertificate(inputStream) as X509Certificate |             return certificateFactory.generateCertificate(inputStream) as X509Certificate | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |     private fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate?): OkHttpClient { | ||||||
|     fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate): OkHttpClient { |  | ||||||
|         try { |         try { | ||||||
|             val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { |             val defaultTmFactory = TrustManagerFactory.getInstance( | ||||||
|                 load(null, null) |                 TrustManagerFactory.getDefaultAlgorithm() | ||||||
|                 setCertificateEntry("ca", trustedCert) |             ).apply { | ||||||
|  |                 init(null as KeyStore?) | ||||||
|  |             } | ||||||
|  |             val defaultTm = defaultTmFactory.trustManagers | ||||||
|  |                 .first { it is X509TrustManager } as X509TrustManager | ||||||
|  |  | ||||||
|  |             val customTm: X509TrustManager? = trustedCert?.let { | ||||||
|  |                 val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { | ||||||
|  |                     load(null, null) | ||||||
|  |                     setCertificateEntry("ca", it) | ||||||
|  |                 } | ||||||
|  |                 val tmf = TrustManagerFactory.getInstance( | ||||||
|  |                     TrustManagerFactory.getDefaultAlgorithm() | ||||||
|  |                 ).apply { | ||||||
|  |                     init(keyStore) | ||||||
|  |                 } | ||||||
|  |                 tmf.trustManagers.first { i -> i is X509TrustManager } as X509TrustManager | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             val tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm() |             val combinedTm = object : X509TrustManager { | ||||||
|             val tmf = TrustManagerFactory.getInstance(tmfAlgorithm).apply { |                 override fun getAcceptedIssuers(): Array<X509Certificate> { | ||||||
|                 init(keyStore) |                     return (defaultTm.acceptedIssuers + (customTm?.acceptedIssuers ?: emptyArray())) | ||||||
|             } |                 } | ||||||
|  |  | ||||||
|             val trustManager = tmf.trustManagers.first { it is X509TrustManager } as X509TrustManager |                 override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) { | ||||||
|  |                     var passed = false | ||||||
|  |                     try { | ||||||
|  |                         defaultTm.checkClientTrusted(chain, authType) | ||||||
|  |                         passed = true | ||||||
|  |                     } catch (_: CertificateException) { } | ||||||
|  |                     if (!passed && customTm != null) { | ||||||
|  |                         customTm.checkClientTrusted(chain, authType) | ||||||
|  |                         passed = true | ||||||
|  |                     } | ||||||
|  |                     if (!passed) throw CertificateException("Untrusted client certificate chain") | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) { | ||||||
|  |                     var passed = false | ||||||
|  |                     try { | ||||||
|  |                         defaultTm.checkServerTrusted(chain, authType) | ||||||
|  |                         passed = true | ||||||
|  |                     } catch (_: CertificateException) { } | ||||||
|  |                     if (!passed && customTm != null) { | ||||||
|  |                         customTm.checkServerTrusted(chain, authType) | ||||||
|  |                         passed = true | ||||||
|  |                     } | ||||||
|  |                     if (!passed) throw CertificateException("Untrusted server certificate chain") | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|             val sslContext = SSLContext.getInstance("TLS").apply { |             val sslContext = SSLContext.getInstance("TLS").apply { | ||||||
|                 init(null, arrayOf(trustManager), null) |                 init(null, arrayOf(combinedTm), null) | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             return OkHttpClient.Builder() |             return if (base.startsWith("abyss://")) | ||||||
|                 .sslSocketFactory(sslContext.socketFactory, trustManager) |                 OkHttpClient.Builder() | ||||||
|                 .build() |                     .connectionSpecs( | ||||||
|  |                         listOf( | ||||||
|  |                             ConnectionSpec.MODERN_TLS, | ||||||
|  |                             ConnectionSpec.COMPATIBLE_TLS | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                     .proxy( | ||||||
|  |                         Proxy( | ||||||
|  |                             Proxy.Type.HTTP, | ||||||
|  |                             InetSocketAddress("::1", 4095) | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                     .sslSocketFactory(sslContext.socketFactory, combinedTm) | ||||||
|  |                     .build() | ||||||
|  |             else | ||||||
|  |                 OkHttpClient.Builder() | ||||||
|  |                     .connectionSpecs( | ||||||
|  |                         listOf( | ||||||
|  |                             ConnectionSpec.MODERN_TLS, | ||||||
|  |                             ConnectionSpec.COMPATIBLE_TLS | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                     .sslSocketFactory(sslContext.socketFactory, combinedTm) | ||||||
|  |                     .build() | ||||||
|  |  | ||||||
|         } catch (e: Exception) { |         } catch (e: Exception) { | ||||||
|             throw RuntimeException("Failed to create OkHttpClient with dynamic certificate", e) |             throw RuntimeException("Failed to create OkHttpClient with dynamic certificate", e) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |     private fun createOkHttp(): OkHttpClient { | ||||||
|  |         return if (cert == "") | ||||||
|  |             if (base.startsWith("abyss://")) | ||||||
|  |                 OkHttpClient | ||||||
|  |                     .Builder() | ||||||
|  |                     .cookieJar( | ||||||
|  |                         PersistentCookieJar( | ||||||
|  |                             SetCookieCache(), | ||||||
|  |                             SharedPrefsCookiePersistor(context) | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                     .proxy( | ||||||
|  |                         Proxy( | ||||||
|  |                             Proxy.Type.HTTP, | ||||||
|  |                             InetSocketAddress("::1", 4095) | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                     .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS)) | ||||||
|  |                     .eventListener(dnsEventListener) | ||||||
|  |                     .build() | ||||||
|  |             else | ||||||
|  |                 OkHttpClient | ||||||
|  |                     .Builder() | ||||||
|  |                     .cookieJar( | ||||||
|  |                         PersistentCookieJar( | ||||||
|  |                             SetCookieCache(), | ||||||
|  |                             SharedPrefsCookiePersistor(context) | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                     .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS)) | ||||||
|  |                     .eventListener(dnsEventListener) | ||||||
|  |                     .build() | ||||||
|  |         else | ||||||
|  |             createOkHttpClientWithDynamicCert(loadCertificateFromString(cert)) | ||||||
|  |  | ||||||
|     fun createOkHttp(): OkHttpClient |  | ||||||
|     { |  | ||||||
|         return createOkHttpClientWithDynamicCert(loadCertificateFromString(cert)) |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun createRetrofit(): Retrofit { |     private fun createRetrofit(): Retrofit { | ||||||
|         val okHttpClient = createOkHttpClientWithDynamicCert(loadCertificateFromString(cert)) |         client = createOkHttp() | ||||||
|  |         val b = replaceAbyssProtocol(base) | ||||||
|  |  | ||||||
|         return Retrofit.Builder() |         return Retrofit.Builder() | ||||||
|             .baseUrl(base) |             .baseUrl(b) | ||||||
|             .client(okHttpClient) |             .client(client!!) | ||||||
|             .addConverterFactory(GsonConverterFactory.create()) |             .addConverterFactory(GsonConverterFactory.create()) | ||||||
|             .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) |             .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) | ||||||
|             .build() |             .build() | ||||||
|     } |     } | ||||||
|  |     private var client: OkHttpClient? = null | ||||||
|  |  | ||||||
|     var api: ApiInterface? = null |     var api: ApiInterface? = null | ||||||
|  |  | ||||||
|     fun apply(url: String, crt: String) |     fun getClient() = client!! | ||||||
|     { |  | ||||||
|         try{ |     suspend fun apply(context: Context, urls: String, crt: String): String? { | ||||||
|             domain = url.toHttpUrlOrNull()?.host !! |         try { | ||||||
|  |             client = createOkHttp() | ||||||
|  |  | ||||||
|  |             val urlList = urls.split(";").map { it.trim() } | ||||||
|  |  | ||||||
|  |             var selectedUrl: String? = null | ||||||
|  |             for (url in urlList) { | ||||||
|  |                 val host = url.toUri().host | ||||||
|  |                 if (host != null && pingHost(host)) { | ||||||
|  |                     selectedUrl = url | ||||||
|  |                     break | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (selectedUrl == null) { | ||||||
|  |                 throw Exception("No reachable URL found") | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             domain = replaceAbyssProtocol(selectedUrl).toHttpUrlOrNull()?.host ?: "" | ||||||
|             cert = crt |             cert = crt | ||||||
|             base = url |             base = selectedUrl | ||||||
|  |             withContext(Dispatchers.IO) | ||||||
|  |             { | ||||||
|  |                 (context as AetherApp).abyssService?.proxy?.config(getBase().toUri().host!!, 4096) | ||||||
|  |             } | ||||||
|             api = createRetrofit().create(ApiInterface::class.java) |             api = createRetrofit().create(ApiInterface::class.java) | ||||||
|         }catch (e: Exception) |             return base | ||||||
|         { |         } catch (_: Exception) { | ||||||
|             api = null |             api = null | ||||||
|             base = "" |             base = "" | ||||||
|             domain = "" |             domain = "" | ||||||
|             cert = "" |             cert = "" | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private suspend fun pingHost(host: String): Boolean = withContext(Dispatchers.IO) { | ||||||
|  |         return@withContext try { | ||||||
|  |             val address = InetAddress.getByName(host) | ||||||
|  |             address.isReachable(200) | ||||||
|  |         } catch (_: Exception) { | ||||||
|  |             false | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,46 +1,46 @@ | |||||||
| package com.acitelight.aether.service | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import com.acitelight.aether.model.BookMark | ||||||
| import com.acitelight.aether.model.ChallengeResponse | import com.acitelight.aether.model.ChallengeResponse | ||||||
| import com.acitelight.aether.model.Comic | import com.acitelight.aether.model.ComicResponse | ||||||
| import com.acitelight.aether.model.VideoResponse | import com.acitelight.aether.model.VideoResponse | ||||||
|  |  | ||||||
| import okhttp3.ResponseBody | import okhttp3.ResponseBody | ||||||
| import retrofit2.http.Body | import retrofit2.http.Body | ||||||
| import retrofit2.http.GET | import retrofit2.http.GET | ||||||
| import retrofit2.http.POST | import retrofit2.http.POST | ||||||
| import retrofit2.http.Path | import retrofit2.http.Path | ||||||
| import retrofit2.http.Query |  | ||||||
| import retrofit2.http.Streaming |  | ||||||
|  |  | ||||||
| interface ApiInterface { | interface ApiInterface { | ||||||
|     @GET("api/video") |     @GET("api/video") | ||||||
|     suspend fun getVideoClasses( |     suspend fun getVideoClasses( | ||||||
|         @Query("token") token: String |  | ||||||
|     ): List<String> |     ): List<String> | ||||||
|     @GET("api/video/{klass}") |     @GET("api/video/{klass}") | ||||||
|     suspend fun queryVideoClasses( |     suspend fun queryVideoClasses( | ||||||
|         @Path("klass") klass: String, |         @Path("klass") klass: String | ||||||
|         @Query("token") token: String |  | ||||||
|     ): List<String> |     ): List<String> | ||||||
|     @GET("api/video/{klass}/{id}") |     @GET("api/video/{klass}/{id}") | ||||||
|     suspend fun queryVideo( |     suspend fun queryVideo( | ||||||
|         @Path("klass") klass: String, |         @Path("klass") klass: String, | ||||||
|         @Path("id") id: String, |         @Path("id") id: String | ||||||
|         @Query("token") token: String |  | ||||||
|     ): VideoResponse |     ): VideoResponse | ||||||
|  |  | ||||||
|     @GET("api/video/{klass}/{id}/nv") |     @POST("api/video/{klass}/bulkquery") | ||||||
|     @Streaming |     suspend fun queryVideoBulk( | ||||||
|     suspend fun getNailVideo( |  | ||||||
|         @Path("klass") klass: String, |         @Path("klass") klass: String, | ||||||
|         @Path("id") id: String, |         @Body() id: List<String> | ||||||
|         @Query("token") token: String |     ): List<VideoResponse> | ||||||
|     ): ResponseBody |  | ||||||
|  |  | ||||||
|     @GET("api/image/collections") |     @GET("api/image") | ||||||
|     suspend fun getComicCollections(): List<String> |     suspend fun getComics(): List<String> | ||||||
|     @GET("api/image/meta") |     @GET("api/image/{id}") | ||||||
|     suspend fun queryComicInfo(@Query("collection") collection: String): Comic |     suspend fun queryComicInfo(@Path("id") id: String): ComicResponse | ||||||
|  |  | ||||||
|  |     @POST("api/image/bulkquery") | ||||||
|  |     suspend fun queryComicInfoBulk(@Body() id: List<String>): List<ComicResponse> | ||||||
|  |  | ||||||
|  |     @POST("api/image/{id}/bookmark") | ||||||
|  |     suspend fun postBookmark(@Path("id") id: String, @Body bookmark: BookMark) | ||||||
|  |  | ||||||
|     @GET("api/user/{user}") |     @GET("api/user/{user}") | ||||||
|     suspend fun getChallenge( |     suspend fun getChallenge( | ||||||
| @@ -52,4 +52,7 @@ interface ApiInterface { | |||||||
|         @Path("user") user: String, |         @Path("user") user: String, | ||||||
|         @Body challengeResponse: ChallengeResponse |         @Body challengeResponse: ChallengeResponse | ||||||
|     ): ResponseBody |     ): ResponseBody | ||||||
|  |  | ||||||
|  |     @GET("api/abyss") | ||||||
|  |     suspend fun hello(): ResponseBody | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,29 +1,45 @@ | |||||||
| package com.acitelight.aether.service | package com.acitelight.aether.service | ||||||
|  |  | ||||||
| import android.util.Base64 | import android.util.Base64 | ||||||
|  | import android.util.Log | ||||||
| import com.acitelight.aether.model.ChallengeResponse | import com.acitelight.aether.model.ChallengeResponse | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.runBlocking | import kotlinx.coroutines.runBlocking | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  | import okhttp3.Call | ||||||
|  | import okhttp3.EventListener | ||||||
|  | import okhttp3.Handshake | ||||||
|  | import okhttp3.OkHttpClient | ||||||
|  | import okhttp3.Request | ||||||
| import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters | import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters | ||||||
| import org.bouncycastle.crypto.signers.Ed25519Signer | import org.bouncycastle.crypto.signers.Ed25519Signer | ||||||
|  | import java.io.IOException | ||||||
|  | import java.lang.reflect.Proxy | ||||||
|  | import java.net.InetSocketAddress | ||||||
| import java.security.PrivateKey | import java.security.PrivateKey | ||||||
| import java.security.Signature | import java.security.Signature | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
| object AuthManager { | @Singleton | ||||||
|  | class AuthManager @Inject constructor( | ||||||
|  |     private val apiClient: ApiClient | ||||||
|  | ) { | ||||||
|     suspend fun fetchToken(username: String, privateKey: String): String? { |     suspend fun fetchToken(username: String, privateKey: String): String? { | ||||||
|         val api = ApiClient.api |         val api = apiClient.api | ||||||
|         var challengeBase64 = "" |         var challengeBase64 = "" | ||||||
|  |  | ||||||
|         try{ |         try{ | ||||||
|             challengeBase64 = api!!.getChallenge(username).string() |             challengeBase64 = api!!.getChallenge(username).string() | ||||||
|         }catch (e: Exception) |         }catch (e: Exception) | ||||||
|         { |         { | ||||||
|             print(e.message) |             return null | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64)) |         val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64)) | ||||||
|  |  | ||||||
|         return try { |         return try { | ||||||
|             api!!.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string() |             api.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string() | ||||||
|         } catch (e: Exception) { |         } catch (e: Exception) { | ||||||
|             e.printStackTrace() |             e.printStackTrace() | ||||||
|             null |             null | ||||||
| @@ -44,4 +60,14 @@ object AuthManager { | |||||||
|         val signature = signer.generateSignature() |         val signature = signer.generateSignature() | ||||||
|         return Base64.encodeToString(signature, Base64.NO_WRAP) |         return Base64.encodeToString(signature, Base64.NO_WRAP) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun signChallengeByte(privateKey: ByteArray, data: ByteArray): ByteArray //64 Byte | ||||||
|  |     { | ||||||
|  |         val privateKeyParams = Ed25519PrivateKeyParameters(privateKey, 0) | ||||||
|  |         val signer = Ed25519Signer() | ||||||
|  |         signer.init(true, privateKeyParams) | ||||||
|  |  | ||||||
|  |         signer.update(data, 0, data.size) | ||||||
|  |         return signer.generateSignature() | ||||||
|  |     } | ||||||
| } | } | ||||||
							
								
								
									
										182
									
								
								app/src/main/java/com/acitelight/aether/service/FetchManager.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,182 @@ | |||||||
|  | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.tonyodev.fetch2.Download | ||||||
|  | import com.tonyodev.fetch2.Fetch | ||||||
|  | import com.tonyodev.fetch2.FetchConfiguration | ||||||
|  | import com.tonyodev.fetch2.FetchListener | ||||||
|  | import com.tonyodev.fetch2.Request | ||||||
|  | import com.tonyodev.fetch2core.Extras | ||||||
|  | import com.tonyodev.fetch2okhttp.OkHttpDownloader | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.coroutines.flow.MutableStateFlow | ||||||
|  | import kotlinx.coroutines.flow.filter | ||||||
|  | import kotlinx.coroutines.flow.first | ||||||
|  | import kotlinx.coroutines.flow.update | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import java.io.File | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
|  | @Singleton | ||||||
|  | class FetchManager @Inject constructor( | ||||||
|  |     @ApplicationContext private val context: Context, | ||||||
|  |     private val apiClient: ApiClient | ||||||
|  | ) { | ||||||
|  |     private var fetch: Fetch? = null | ||||||
|  |     private var listener: FetchListener? = null | ||||||
|  |     val configured = MutableStateFlow(false) | ||||||
|  |  | ||||||
|  |     fun init() { | ||||||
|  |         val fetchConfiguration = FetchConfiguration.Builder(context) | ||||||
|  |             .setDownloadConcurrentLimit(8) | ||||||
|  |             .setHttpDownloader(OkHttpDownloader(apiClient.getClient())) | ||||||
|  |             .build() | ||||||
|  |  | ||||||
|  |         fetch = Fetch.Impl.getInstance(fetchConfiguration) | ||||||
|  |         configured.update { true } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // listener management | ||||||
|  |     suspend fun setListener(l: FetchListener) { | ||||||
|  |         configured.filter { it }.first() | ||||||
|  |  | ||||||
|  |         listener?.let { fetch?.removeListener(it) } | ||||||
|  |         listener = l | ||||||
|  |         fetch?.addListener(l) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun removeListener() { | ||||||
|  |         listener?.let { | ||||||
|  |             fetch?.removeListener(it) | ||||||
|  |         } | ||||||
|  |         listener = null | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun getAllDownloadsAsync(): List<Download> { | ||||||
|  |         configured.filter { it }.first() | ||||||
|  |         val completed = MutableStateFlow(false) | ||||||
|  |         var r = listOf<Download>() | ||||||
|  |  | ||||||
|  |         fetch?.getDownloads { list -> | ||||||
|  |             r = list | ||||||
|  |             completed.update { true } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         completed.filter { it }.first() | ||||||
|  |         return r | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // operations | ||||||
|  |     fun pause(id: Int) { | ||||||
|  |         fetch?.pause(id) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun resume(id: Int) { | ||||||
|  |         fetch?.resume(id) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun retry(id: Int) { | ||||||
|  |         fetch?.retry(id) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun cancel(id: Int) { | ||||||
|  |         fetch?.cancel(id) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun delete(id: Int, callback: (() -> Unit)? = null) { | ||||||
|  |         fetch?.delete(id) { | ||||||
|  |             callback?.invoke() | ||||||
|  |         } ?: callback?.invoke() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private suspend fun enqueue( | ||||||
|  |         request: Request, | ||||||
|  |         onEnqueued: ((Request) -> Unit)? = null, | ||||||
|  |         onError: ((com.tonyodev.fetch2.Error) -> Unit)? = null | ||||||
|  |     ) { | ||||||
|  |         configured.filter { it }.first() | ||||||
|  |         fetch?.enqueue(request, { r -> onEnqueued?.invoke(r) }, { e -> onError?.invoke(e) }) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun makeFolder(video: Video) { | ||||||
|  |         val appFilesDir = context.getExternalFilesDir(null) | ||||||
|  |         val videosDir = File(appFilesDir, "videos/${video.klass}/${video.id}/gallery") | ||||||
|  |         videosDir.mkdirs() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun startVideoDownload(video: Video) { | ||||||
|  |         if(getAllDownloadsAsync().any{ | ||||||
|  |             it.extras.getString("class", "") == video.klass && it.extras.getString("id", "") == video.id }) | ||||||
|  |             return | ||||||
|  |  | ||||||
|  |         makeFolder(video) | ||||||
|  |         File( | ||||||
|  |             context.getExternalFilesDir(null), | ||||||
|  |             "videos/${video.klass}/${video.id}/summary.json" | ||||||
|  |         ).writeText(Json.encodeToString(video)) | ||||||
|  |  | ||||||
|  |         val videoPath = | ||||||
|  |             File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/video.mp4") | ||||||
|  |         val coverPath = | ||||||
|  |             File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/cover.jpg") | ||||||
|  |         val subtitlePath = File( | ||||||
|  |             context.getExternalFilesDir(null), | ||||||
|  |             "videos/${video.klass}/${video.id}/subtitle.vtt" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         val requests = mutableListOf( | ||||||
|  |             Request(video.getVideo(apiClient), videoPath.path).apply { | ||||||
|  |                 extras = Extras( | ||||||
|  |                     mapOf( | ||||||
|  |                         "name" to video.video.name, | ||||||
|  |                         "id" to video.id, | ||||||
|  |                         "class" to video.klass, | ||||||
|  |                         "type" to "main" | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             }, | ||||||
|  |             Request(video.getCover(apiClient), coverPath.path).apply { | ||||||
|  |                 extras = Extras( | ||||||
|  |                     mapOf( | ||||||
|  |                         "name" to video.video.name, | ||||||
|  |                         "id" to video.id, | ||||||
|  |                         "class" to video.klass, | ||||||
|  |                         "type" to "cover" | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             }, | ||||||
|  |             Request(video.getSubtitle(apiClient), subtitlePath.path).apply { | ||||||
|  |                 extras = Extras( | ||||||
|  |                     mapOf( | ||||||
|  |                         "name" to video.video.name, | ||||||
|  |                         "id" to video.id, | ||||||
|  |                         "class" to video.klass, | ||||||
|  |                         "type" to "subtitle" | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         for (p in video.getGallery(apiClient)) { | ||||||
|  |             requests.add( | ||||||
|  |                 Request(p.url, File( | ||||||
|  |                     context.getExternalFilesDir(null), | ||||||
|  |                     "videos/${video.klass}/${video.id}/gallery/${p.name}" | ||||||
|  |                 ).path).apply { | ||||||
|  |                     extras = Extras( | ||||||
|  |                         mapOf( | ||||||
|  |                             "name" to video.video.name, | ||||||
|  |                             "id" to video.id, | ||||||
|  |                             "class" to video.klass, | ||||||
|  |                             "type" to "gallery" | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         for (i in requests) | ||||||
|  |             enqueue(i) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,62 +1,198 @@ | |||||||
| package com.acitelight.aether.service | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import com.acitelight.aether.model.BookMark | ||||||
| import com.acitelight.aether.model.Comic | import com.acitelight.aether.model.Comic | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import kotlinx.coroutines.Dispatchers | import com.acitelight.aether.model.VideoDownloadItemState | ||||||
| import kotlinx.coroutines.withContext | import com.tonyodev.fetch2.Status | ||||||
| import java.io.IOException | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import java.io.File | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
|  |  | ||||||
| object MediaManager | @Singleton | ||||||
|  | class MediaManager @Inject constructor( | ||||||
|  |     val fetchManager: FetchManager, | ||||||
|  |     @ApplicationContext val context: Context, | ||||||
|  |     private val apiClient: ApiClient | ||||||
|  | ) | ||||||
| { | { | ||||||
|     var token: String = "null" |  | ||||||
|  |  | ||||||
|     suspend fun listVideoKlasses(): List<String> |     suspend fun listVideoKlasses(): List<String> | ||||||
|     { |     { | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             val j = ApiClient.api!!.getVideoClasses(token) |             val j = apiClient.api!!.getVideoClasses() | ||||||
|             return j.toList() |             return j.toList() | ||||||
|         }catch(e: Exception) |         }catch(_: Exception) | ||||||
|         { |         { | ||||||
|             return listOf() |             return listOf() | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun listVideos(klass: String, callback: (Video) -> Unit) |     suspend fun queryVideoKlasses(klass: String): List<String> | ||||||
|     { |     { | ||||||
|         val j = ApiClient.api!!.queryVideoClasses(klass, token) |         try | ||||||
|         for(it in j) |  | ||||||
|         { |         { | ||||||
|             try { |             val j = apiClient.api!!.queryVideoClasses(klass) | ||||||
|                 callback(queryVideo(klass, it)!!) |             return j.toList() | ||||||
|             }catch (e: Exception) |         }catch(_: Exception) | ||||||
|             { |         { | ||||||
|  |             return listOf() | ||||||
|             } |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun queryVideo(klass: String, id: String): Video? |     suspend fun queryVideo(klass: String, id: String, model: VideoDownloadItemState): Video? | ||||||
|     { |     { | ||||||
|  |         if(model.status == Status.COMPLETED) | ||||||
|  |         { | ||||||
|  |             val jsonString = File( | ||||||
|  |                 context.getExternalFilesDir(null), | ||||||
|  |                 "videos/$klass/$id/summary.json" | ||||||
|  |             ).readText() | ||||||
|  |             return Json.decodeFromString<Video>(jsonString).toLocal(context.getExternalFilesDir(null)?.path!!) | ||||||
|  |         } | ||||||
|  |  | ||||||
|         try { |         try { | ||||||
|             val j = ApiClient.api!!.queryVideo(klass, id, token) |             val j = apiClient.api!!.queryVideo(klass, id) | ||||||
|             return Video(klass = klass, id = id, token=token, j) |             return Video(klass = klass, id = id, isLocal = false, localBase = "", video = j) | ||||||
|         }catch (e: Exception) |         }catch (_: Exception) | ||||||
|         { |         { | ||||||
|             return null |             return null | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun listComics() : List<String> |     suspend fun queryVideo(klass: String, id: String): Video? | ||||||
|     { |     { | ||||||
|         // TODO: try |         val downloaded = fetchManager.getAllDownloadsAsync().filter { | ||||||
|         return ApiClient.api!!.getComicCollections() |             it.extras.getString("id", "") == id && | ||||||
|  |             it.extras.getString("class", "") == klass | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if(downloaded.any{ it.status == Status.COMPLETED } | ||||||
|  |             && downloaded.all{ it.status == Status.COMPLETED || it.extras.getString("type", "") == "subtitle" }) | ||||||
|  |         { | ||||||
|  |             val jsonString = File( | ||||||
|  |                 context.getExternalFilesDir(null), | ||||||
|  |                 "videos/$klass/$id/summary.json" | ||||||
|  |             ).readText() | ||||||
|  |             return Json.decodeFromString<Video>(jsonString).toLocal(context.getExternalFilesDir(null)?.path!!) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         try { | ||||||
|  |             val j = apiClient.api!!.queryVideo(klass, id) | ||||||
|  |             return Video(klass = klass, id = id, isLocal = false, localBase = "", video = j) | ||||||
|  |         }catch (_: Exception) | ||||||
|  |         { | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun queryComicInfo(c: String) : Comic |     suspend fun queryVideoBulk(klass: String, id: List<String>): List<Video>? { | ||||||
|  |         return try { | ||||||
|  |             val downloads = fetchManager.getAllDownloadsAsync() | ||||||
|  |  | ||||||
|  |             val localIds = mutableSetOf<String>() | ||||||
|  |             val remoteIds = mutableListOf<String>() | ||||||
|  |  | ||||||
|  |             for (videoId in id) { | ||||||
|  |                 val o = downloads.filter { | ||||||
|  |                     it.extras.getString("id", "") == videoId && | ||||||
|  |                             it.extras.getString("class", "") == klass | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 if (o.any{ it.status == Status.COMPLETED } | ||||||
|  |                     && o.all{ it.status == Status.COMPLETED || it.extras.getString("type", "") == "subtitle" }) | ||||||
|  |                 { | ||||||
|  |                     localIds.add(videoId) | ||||||
|  |                 } else { | ||||||
|  |                     remoteIds.add(videoId) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             val localVideos = localIds.mapNotNull { videoId -> | ||||||
|  |                 val localFile = File( | ||||||
|  |                     context.getExternalFilesDir(null), | ||||||
|  |                     "videos/$klass/$videoId/summary.json" | ||||||
|  |                 ) | ||||||
|  |                 if (localFile.exists()) { | ||||||
|  |                     try { | ||||||
|  |                         val jsonString = localFile.readText() | ||||||
|  |                         Json.decodeFromString<Video>(jsonString).toLocal( | ||||||
|  |                             context.getExternalFilesDir(null)?.path ?: "" | ||||||
|  |                         ) | ||||||
|  |                     } catch (_: Exception) { | ||||||
|  |                         null | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     null | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             val remoteVideos = if (remoteIds.isNotEmpty()) { | ||||||
|  |                 val j = apiClient.api!!.queryVideoBulk(klass, remoteIds) | ||||||
|  |                 j.zip(remoteIds).map { | ||||||
|  |                     Video( | ||||||
|  |                         klass = klass, | ||||||
|  |                         id = it.second, | ||||||
|  |                         isLocal = false, | ||||||
|  |                         localBase = "", | ||||||
|  |                         video = it.first | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 emptyList() | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             localVideos + remoteVideos | ||||||
|  |         } catch (_: Exception) { | ||||||
|  |             null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun listComics() : List<String> | ||||||
|     { |     { | ||||||
|         // TODO: try |         try{ | ||||||
|         return ApiClient.api!!.queryComicInfo(c) |             val j = apiClient.api!!.getComics() | ||||||
|  |             return j.sorted() | ||||||
|  |         }catch (_: Exception) | ||||||
|  |         { | ||||||
|  |             return listOf() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun queryComicInfoSingle(id: String) : Comic? | ||||||
|  |     { | ||||||
|  |         try{ | ||||||
|  |             val j = apiClient.api!!.queryComicInfo(id) | ||||||
|  |             return Comic(id = id, comic = j) | ||||||
|  |         }catch (_: Exception) | ||||||
|  |         { | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun queryComicInfoBulk(id: List<String>) : List<Comic>? | ||||||
|  |     { | ||||||
|  |         try{ | ||||||
|  |             val j = apiClient.api!!.queryComicInfoBulk(id) | ||||||
|  |             return j.zip(id).map { Comic(id = it.second, comic = it.first) } | ||||||
|  |         }catch (_: Exception) | ||||||
|  |         { | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun postBookmark(id: String, bookMark: BookMark): Boolean | ||||||
|  |     { | ||||||
|  |         try{ | ||||||
|  |             apiClient.api!!.postBookmark(id, bookMark) | ||||||
|  |             return true | ||||||
|  |         }catch (_: Exception) | ||||||
|  |         { | ||||||
|  |             return false | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -2,11 +2,10 @@ package com.acitelight.aether.service | |||||||
|  |  | ||||||
| import android.content.Context | import android.content.Context | ||||||
| import androidx.compose.runtime.mutableStateListOf | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import com.acitelight.aether.model.Comic | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import com.acitelight.aether.model.VideoQueryIndex | import com.acitelight.aether.model.VideoQueryIndex | ||||||
| import kotlinx.coroutines.Dispatchers | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.flow.MutableStateFlow |  | ||||||
| import kotlinx.coroutines.flow.StateFlow |  | ||||||
| import kotlinx.coroutines.sync.Mutex | import kotlinx.coroutines.sync.Mutex | ||||||
| import kotlinx.coroutines.sync.withLock | import kotlinx.coroutines.sync.withLock | ||||||
| import kotlinx.coroutines.withContext | import kotlinx.coroutines.withContext | ||||||
| @@ -14,26 +13,31 @@ import kotlinx.serialization.json.* | |||||||
| import java.io.File | import java.io.File | ||||||
| import java.io.FileNotFoundException | import java.io.FileNotFoundException | ||||||
| import java.io.IOException | import java.io.IOException | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
| object RecentManager | @Singleton | ||||||
|  | class RecentManager @Inject constructor( | ||||||
|  |     private val mediaManager: MediaManager | ||||||
|  | ) | ||||||
| { | { | ||||||
|     private val mutex = Mutex() |     private val mutex = Mutex() | ||||||
|  |  | ||||||
|     suspend fun readFile(context: Context, filename: String): String { |     private suspend fun readFile(context: Context, filename: String): String { | ||||||
|         return withContext(Dispatchers.IO) { |         return withContext(Dispatchers.IO) { | ||||||
|             try { |             try { | ||||||
|                 val file = File(context.filesDir, filename) |                 val file = File(context.filesDir, filename) | ||||||
|                 val content = file.readText() |                 val content = file.readText() | ||||||
|                 content |                 content | ||||||
|             } catch (e: FileNotFoundException) { |             } catch (_: FileNotFoundException) { | ||||||
|                 "[]" |                 "[]" | ||||||
|             } catch (e: IOException) { |             } catch (_: IOException) { | ||||||
|                 "[]" |                 "[]" | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun writeFile(context: Context, filename: String, content: String) { |     private suspend fun writeFile(context: Context, filename: String, content: String) { | ||||||
|         withContext(Dispatchers.IO) { |         withContext(Dispatchers.IO) { | ||||||
|             try { |             try { | ||||||
|                 val file = File(context.filesDir, filename) |                 val file = File(context.filesDir, filename) | ||||||
| @@ -45,19 +49,92 @@ object RecentManager | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun Query(context: Context): List<VideoQueryIndex> |     suspend fun queryComic(context: Context): List<String> { | ||||||
|  |         val content = readFile(context, "recent_comic.json") | ||||||
|  |         try { | ||||||
|  |             val ids = Json.decodeFromString<List<String>>(content) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             recentComic.clear() | ||||||
|  |  | ||||||
|  |             try { | ||||||
|  |                 val comics = mediaManager.queryComicInfoBulk(ids) | ||||||
|  |                 if (comics != null) { | ||||||
|  |                     for (c in comics) { | ||||||
|  |                         recentComic.add(recentComic.size, c) | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     for (id in ids) { | ||||||
|  |                         val c = mediaManager.queryComicInfoSingle(id) | ||||||
|  |                         if (c != null) recentComic.add(recentComic.size, c) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } catch (_: NoSuchMethodError) { | ||||||
|  |                 for (id in ids) { | ||||||
|  |                     val c = mediaManager.queryComicInfoSingle(id) | ||||||
|  |                     if (c != null) recentComic.add(recentComic.size, c) | ||||||
|  |                 } | ||||||
|  |             } catch (_: Exception) { | ||||||
|  |                 for (id in ids) { | ||||||
|  |                     val c = mediaManager.queryComicInfoSingle(id) | ||||||
|  |                     if (c != null) recentComic.add(recentComic.size, c) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             return ids | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             print(e.message) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |         return listOf() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun pushComic(context: Context, comicId: String) { | ||||||
|  |         mutex.withLock { | ||||||
|  |             val o = recentComic.map { it.id }.toMutableList() | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             if (o.contains(comicId)) { | ||||||
|  |                 val index = o.indexOf(comicId) | ||||||
|  |                 recentComic.removeAt(index) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             val comic = mediaManager.queryComicInfoSingle(comicId) | ||||||
|  |             if (comic != null) { | ||||||
|  |                 recentComic.add(0, comic) | ||||||
|  |             } else { | ||||||
|  |                 return | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (recentComic.size > 21) { | ||||||
|  |                 recentComic.removeAt(recentComic.size - 1) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             writeFile(context, "recent_comic.json", Json.encodeToString(recentComic.map { it.id })) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun queryVideo(context: Context): List<VideoQueryIndex> | ||||||
|     { |     { | ||||||
|         val content = readFile(context, "recent.json") |         val content = readFile(context, "recent.json") | ||||||
|         try{ |         try{ | ||||||
|             val r = Json.decodeFromString<List<VideoQueryIndex>>(content) |             val r = Json.decodeFromString<List<VideoQueryIndex>>(content) | ||||||
|  |  | ||||||
|             recent.clear() |             recentVideo.clear() | ||||||
|  |             val gr = r.groupBy { it.klass } | ||||||
|  |  | ||||||
|             for(it in r) |             for(it in gr) | ||||||
|             { |             { | ||||||
|                 val v = MediaManager.queryVideo(it.klass, it.id) |                 val v = mediaManager.queryVideoBulk(it.key, it.value.map { it.id }) | ||||||
|                 if(v != null) |                 if(v != null) | ||||||
|                     recent.add(recent.size, v) |                     for(j in v) | ||||||
|  |                     { | ||||||
|  |                         recentVideo.add(recentVideo.size, j) | ||||||
|  |                     } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             return r |             return r | ||||||
| @@ -69,32 +146,26 @@ object RecentManager | |||||||
|         return listOf() |         return listOf() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun Push(context: Context, video: VideoQueryIndex) |     suspend fun pushVideo(context: Context, video: VideoQueryIndex) | ||||||
|     { |     { | ||||||
|         mutex.withLock{ |         mutex.withLock{ | ||||||
|             val content = readFile(context, "recent.json") |             val o = recentVideo.map{ VideoQueryIndex(it.klass, it.id) }.toMutableList() | ||||||
|             var o = Json.decodeFromString<List<VideoQueryIndex>>(content).toMutableList(); |  | ||||||
|  |  | ||||||
|             if(o.contains(video)) |             if(o.contains(video)) | ||||||
|             { |             { | ||||||
|                 val temp = o[0] |  | ||||||
|                 val index = o.indexOf(video) |                 val index = o.indexOf(video) | ||||||
|                 recent.removeAt(index) |                 recentVideo.removeAt(index) | ||||||
|                 o[0] = o[index] |  | ||||||
|                 o[index] = temp |  | ||||||
|             } |  | ||||||
|             else |  | ||||||
|             { |  | ||||||
|                 o.add(0, video) |  | ||||||
|             } |             } | ||||||
|  |             recentVideo.add(0, mediaManager.queryVideo(video.klass, video.id)!!) | ||||||
|  |  | ||||||
|             if(o.size >= 21) |  | ||||||
|                 o.removeAt(o.size - 1) |  | ||||||
|  |  | ||||||
|             recent.add(0, MediaManager.queryVideo(video.klass, video.id)!!) |             if(recentVideo.size >= 21) | ||||||
|             writeFile(context, "recent.json", Json.encodeToString(o)) |                 recentVideo.removeAt(o.size - 1) | ||||||
|  |  | ||||||
|  |             writeFile(context, "recent.json", Json.encodeToString(recentVideo.map{ VideoQueryIndex(it.klass, it.id) })) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     val recent = mutableStateListOf<Video>() |     val recentVideo = mutableStateListOf<Video>() | ||||||
|  |     val recentComic = mutableStateListOf<Comic>() | ||||||
| } | } | ||||||
| @@ -0,0 +1,85 @@ | |||||||
|  | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | 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 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 = "settings") | ||||||
|  |  | ||||||
|  | @Singleton | ||||||
|  | class SettingsDataStoreManager @Inject constructor( | ||||||
|  |     @ApplicationContext private val context: Context | ||||||
|  | ) { | ||||||
|  |     companion object { | ||||||
|  |         val USER_NAME_KEY = stringPreferencesKey("user_name") | ||||||
|  |         val PRIVATE_KEY = stringPreferencesKey("private_key") | ||||||
|  |         val URL_KEY = stringPreferencesKey("url") | ||||||
|  |         val CERT_KEY = stringPreferencesKey("cert") | ||||||
|  |         val USE_SELF_SIGNED_KEY = booleanPreferencesKey("use_self_signed") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val userNameFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||||
|  |         preferences[USER_NAME_KEY] ?: "" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val privateKeyFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||||
|  |         preferences[PRIVATE_KEY] ?: "" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val urlFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||||
|  |         preferences[URL_KEY] ?: "" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val certFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||||
|  |         preferences[CERT_KEY] ?: "" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val useSelfSignedFlow: Flow<Boolean> = context.dataStore.data.map { preferences -> | ||||||
|  |         preferences[USE_SELF_SIGNED_KEY] ?: false | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun saveUserName(name: String) { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences[USER_NAME_KEY] = name | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun savePrivateKey(key: String) { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences[PRIVATE_KEY] = key | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun saveUrl(url: String) { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences[URL_KEY] = url | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun saveCert(cert: String) { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences[CERT_KEY] = cert | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun saveUseSelfSigned(useSelfSigned: Boolean) { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences[USE_SELF_SIGNED_KEY] = useSelfSigned | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun clearAll() { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences.clear() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,20 @@ | |||||||
|  | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.runtime.mutableStateMapOf | ||||||
|  | import androidx.compose.runtime.snapshots.SnapshotStateList | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
|  | @Singleton | ||||||
|  | class VideoLibrary @Inject constructor( | ||||||
|  |     @ApplicationContext private val context: Context | ||||||
|  | )  { | ||||||
|  |  | ||||||
|  |     var classes = mutableStateListOf<String>() | ||||||
|  |     val classesMap = mutableStateMapOf<String, SnapshotStateList<Video>>() | ||||||
|  |     val updatingMap: MutableMap<Int, Boolean> = mutableMapOf() | ||||||
|  | } | ||||||
| @@ -3,35 +3,135 @@ package com.acitelight.aether.ui.theme | |||||||
| import android.app.Activity | import android.app.Activity | ||||||
| import android.os.Build | import android.os.Build | ||||||
| import androidx.compose.foundation.isSystemInDarkTheme | import androidx.compose.foundation.isSystemInDarkTheme | ||||||
|  | import androidx.compose.material3.ColorScheme | ||||||
| import androidx.compose.material3.MaterialTheme | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.darkColorScheme | import androidx.compose.material3.darkColorScheme | ||||||
| import androidx.compose.material3.dynamicDarkColorScheme | import androidx.compose.material3.dynamicDarkColorScheme | ||||||
| import androidx.compose.material3.dynamicLightColorScheme | import androidx.compose.material3.dynamicLightColorScheme | ||||||
| import androidx.compose.material3.lightColorScheme | import androidx.compose.material3.lightColorScheme | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.graphics.lerp | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.compose.ui.platform.LocalContext | ||||||
|  |  | ||||||
| private val DarkColorScheme = darkColorScheme( | fun generateColorScheme(primaryColor: Color, isDarkMode: Boolean): ColorScheme { | ||||||
|     primary = Purple80, |  | ||||||
|     secondary = PurpleGrey80, |  | ||||||
|     tertiary = Pink80 |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| private val LightColorScheme = lightColorScheme( |     val background = if (isDarkMode) Color(0xFF121212) else Color(0xFFFFFFFF) | ||||||
|     primary = Purple40, |     val surface = if (isDarkMode) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) | ||||||
|     secondary = PurpleGrey40, |  | ||||||
|     tertiary = Pink40 |  | ||||||
|  |  | ||||||
|     /* Other default colors to override |     val surfaceContainer = if (isDarkMode) Color(0xFF232323) else Color(0xFFFDFDFD) | ||||||
|     background = Color(0xFFFFFBFE), |     val surfaceContainerLow = if (isDarkMode) Color(0xFF1A1A1A) else Color(0xFFF5F5F5) | ||||||
|     surface = Color(0xFFFFFBFE), |     val surfaceContainerHigh = if (isDarkMode) Color(0xFF2A2A2A) else Color(0xFFFAFAFA) | ||||||
|     onPrimary = Color.White, |     val surfaceContainerHighest = if (isDarkMode) Color(0xFF333333) else Color(0xFFFFFFFF) | ||||||
|     onSecondary = Color.White, |     val surfaceContainerLowest = if (isDarkMode) Color(0xFF0F0F0F) else Color(0xFFF0F0F0) | ||||||
|     onTertiary = Color.White, |     val surfaceBright = if (isDarkMode) Color(0xFF2C2C2C) else Color(0xFFFFFFFF) | ||||||
|     onBackground = Color(0xFF1C1B1F), |     val surfaceDim = if (isDarkMode) Color(0xFF141414) else Color(0xFFF8F8F8) | ||||||
|     onSurface = Color(0xFF1C1B1F), |  | ||||||
|     */ |     fun tint(surface: Color, factor: Float) = lerp(surface, primaryColor, factor) | ||||||
| ) |  | ||||||
|  |     return if (isDarkMode) { | ||||||
|  |         darkColorScheme( | ||||||
|  |             primary = primaryColor, | ||||||
|  |             onPrimary = Color.White, | ||||||
|  |             primaryContainer = tint(primaryColor, 0.2f), | ||||||
|  |             onPrimaryContainer = Color.White, | ||||||
|  |             inversePrimary = tint(primaryColor, 0.6f), | ||||||
|  |  | ||||||
|  |             secondary = tint(primaryColor, 0.4f), | ||||||
|  |             onSecondary = Color.White, | ||||||
|  |             secondaryContainer = tint(primaryColor, 0.2f), | ||||||
|  |             onSecondaryContainer = Color.White, | ||||||
|  |  | ||||||
|  |             tertiary = tint(primaryColor, 0.5f), | ||||||
|  |             onTertiary = Color.White, | ||||||
|  |             tertiaryContainer = tint(primaryColor, 0.2f), | ||||||
|  |             onTertiaryContainer = Color.White, | ||||||
|  |  | ||||||
|  |             background = background, | ||||||
|  |             onBackground = Color.White, | ||||||
|  |  | ||||||
|  |             surface = surface, | ||||||
|  |             onSurface = Color.White, | ||||||
|  |  | ||||||
|  |             surfaceVariant = tint(surface, 0.1f), | ||||||
|  |             onSurfaceVariant = Color(0xFFE0E0E0), | ||||||
|  |  | ||||||
|  |             surfaceTint = primaryColor, | ||||||
|  |  | ||||||
|  |             inverseSurface = Color.White, | ||||||
|  |             inverseOnSurface = Color(0xFF121212), | ||||||
|  |  | ||||||
|  |             error = Color(0xFFCF6679), | ||||||
|  |             onError = Color.Black, | ||||||
|  |             errorContainer = Color(0xFFB00020), | ||||||
|  |             onErrorContainer = Color.White, | ||||||
|  |  | ||||||
|  |             outline = Color(0xFF757575), | ||||||
|  |             outlineVariant = Color(0xFF494949), | ||||||
|  |             scrim = Color.Black, | ||||||
|  |  | ||||||
|  |             surfaceBright = tint(surfaceBright, 0.1f), | ||||||
|  |             surfaceContainer = tint(surfaceContainer, 0.1f), | ||||||
|  |             surfaceContainerHigh = tint(surfaceContainerHigh, 0.12f), | ||||||
|  |             surfaceContainerHighest = tint(surfaceContainerHighest, 0.15f), | ||||||
|  |             surfaceContainerLow = tint(surfaceContainerLow, 0.08f), | ||||||
|  |             surfaceContainerLowest = tint(surfaceContainerLowest, 0.05f), | ||||||
|  |             surfaceDim = tint(surfaceDim, 0.1f) | ||||||
|  |         ) | ||||||
|  |     } else { | ||||||
|  |         lightColorScheme( | ||||||
|  |             primary = primaryColor, | ||||||
|  |             onPrimary = Color.White, | ||||||
|  |             primaryContainer = tint(primaryColor, 0.3f), | ||||||
|  |             onPrimaryContainer = Color.Black, | ||||||
|  |             inversePrimary = tint(primaryColor, 0.6f), | ||||||
|  |  | ||||||
|  |             secondary = tint(primaryColor, 0.4f), | ||||||
|  |             onSecondary = Color.Black, | ||||||
|  |             secondaryContainer = tint(primaryColor, 0.2f), | ||||||
|  |             onSecondaryContainer = Color.Black, | ||||||
|  |  | ||||||
|  |             tertiary = tint(primaryColor, 0.5f), | ||||||
|  |             onTertiary = Color.Black, | ||||||
|  |             tertiaryContainer = tint(primaryColor, 0.3f), | ||||||
|  |             onTertiaryContainer = Color.Black, | ||||||
|  |  | ||||||
|  |             background = background, | ||||||
|  |             onBackground = Color.Black, | ||||||
|  |  | ||||||
|  |             surface = surface, | ||||||
|  |             onSurface = Color.Black, | ||||||
|  |  | ||||||
|  |             surfaceVariant = tint(surface, 0.1f), | ||||||
|  |             onSurfaceVariant = Color(0xFF49454F), | ||||||
|  |  | ||||||
|  |             surfaceTint = primaryColor, | ||||||
|  |  | ||||||
|  |             inverseSurface = Color(0xFF121212), | ||||||
|  |             inverseOnSurface = Color.White, | ||||||
|  |  | ||||||
|  |             error = Color(0xFFB00020), | ||||||
|  |             onError = Color.White, | ||||||
|  |             errorContainer = Color(0xFFFFDAD6), | ||||||
|  |             onErrorContainer = Color.Black, | ||||||
|  |  | ||||||
|  |             outline = Color(0xFF737373), | ||||||
|  |             outlineVariant = Color(0xFFD0C4C9), | ||||||
|  |             scrim = Color.Black, | ||||||
|  |  | ||||||
|  |             surfaceBright = tint(surfaceBright, 0.1f), | ||||||
|  |             surfaceContainer = tint(surfaceContainer, 0.1f), | ||||||
|  |             surfaceContainerHigh = tint(surfaceContainerHigh, 0.12f), | ||||||
|  |             surfaceContainerHighest = tint(surfaceContainerHighest, 0.15f), | ||||||
|  |             surfaceContainerLow = tint(surfaceContainerLow, 0.08f), | ||||||
|  |             surfaceContainerLowest = tint(surfaceContainerLowest, 0.05f), | ||||||
|  |             surfaceDim = tint(surfaceDim, 0.05f) | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | private val DarkColorScheme = generateColorScheme(Color(0xFF36A0FF), isDarkMode = true) | ||||||
|  | private val LightColorScheme = generateColorScheme(Color(0xFF36A0FF), isDarkMode = false) | ||||||
|  |  | ||||||
| @Composable | @Composable | ||||||
| fun AetherTheme( | fun AetherTheme( | ||||||
| @@ -41,11 +141,6 @@ fun AetherTheme( | |||||||
|     content: @Composable () -> Unit |     content: @Composable () -> Unit | ||||||
| ) { | ) { | ||||||
|     val colorScheme = when { |     val colorScheme = when { | ||||||
|         dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { |  | ||||||
|             val context = LocalContext.current |  | ||||||
|             if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         darkTheme -> DarkColorScheme |         darkTheme -> DarkColorScheme | ||||||
|         else -> LightColorScheme |         else -> LightColorScheme | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -1,11 +0,0 @@ | |||||||
| package com.acitelight.aether.view |  | ||||||
|  |  | ||||||
| import androidx.compose.runtime.Composable |  | ||||||
| import androidx.lifecycle.viewmodel.compose.viewModel |  | ||||||
| import com.acitelight.aether.viewModel.ComicScreenViewModel |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun ComicScreen(comicScreenViewModel: ComicScreenViewModel = viewModel()) |  | ||||||
| { |  | ||||||
|  |  | ||||||
| } |  | ||||||
| @@ -1,68 +0,0 @@ | |||||||
| package com.acitelight.aether.view |  | ||||||
|  |  | ||||||
| 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.fillMaxWidth |  | ||||||
| import androidx.compose.foundation.layout.height |  | ||||||
| import androidx.compose.foundation.layout.padding |  | ||||||
| import androidx.compose.foundation.lazy.LazyColumn |  | ||||||
| import androidx.compose.material3.Button |  | ||||||
| import androidx.compose.material3.DividerDefaults |  | ||||||
| import androidx.compose.material3.HorizontalDivider |  | ||||||
| import androidx.compose.material3.MaterialTheme |  | ||||||
| import androidx.compose.material3.Text |  | ||||||
| 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.draw.alpha |  | ||||||
| import androidx.compose.ui.tooling.preview.Preview |  | ||||||
| import androidx.compose.ui.unit.dp |  | ||||||
| import androidx.compose.ui.unit.sp |  | ||||||
| import androidx.lifecycle.viewmodel.compose.viewModel |  | ||||||
| import androidx.navigation.NavController |  | ||||||
| import com.acitelight.aether.Global |  | ||||||
| import com.acitelight.aether.service.MediaManager |  | ||||||
| import com.acitelight.aether.service.RecentManager |  | ||||||
| import com.acitelight.aether.viewModel.HomeScreenViewModel |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel(), navController: NavController) |  | ||||||
| { |  | ||||||
|     if(Global.loggedIn) |  | ||||||
|         homeScreenViewModel.Init() |  | ||||||
|  |  | ||||||
|     LazyColumn(modifier = Modifier.fillMaxWidth()) |  | ||||||
|     { |  | ||||||
|         item() |  | ||||||
|         { |  | ||||||
|             Column { |  | ||||||
|                 Text( |  | ||||||
|                     text = "Recent", |  | ||||||
|                     style = MaterialTheme.typography.headlineMedium, |  | ||||||
|                     modifier = Modifier.padding(16.dp).align(Alignment.Start) |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|                 HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) |  | ||||||
|  |  | ||||||
|                 for(i in RecentManager.recent) |  | ||||||
|                 { |  | ||||||
|                     MiniVideoCard( |  | ||||||
|                         modifier = Modifier |  | ||||||
|                             .padding(horizontal = 12.dp), |  | ||||||
|                         i, |  | ||||||
|                         { |  | ||||||
|                             Global.sameClassVideos = RecentManager.recent |  | ||||||
|                             val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }" |  | ||||||
|                             navController.navigate(route) |  | ||||||
|                         }, homeScreenViewModel.imageLoader!!) |  | ||||||
|                     HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color) |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
| } |  | ||||||
| @@ -1,985 +0,0 @@ | |||||||
| package com.acitelight.aether.view |  | ||||||
|  |  | ||||||
| import android.app.Activity |  | ||||||
| import android.content.pm.ActivityInfo |  | ||||||
| import android.content.res.Configuration |  | ||||||
| import androidx.activity.compose.BackHandler |  | ||||||
| import androidx.compose.animation.AnimatedVisibility |  | ||||||
| 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.layout.Box |  | ||||||
| import androidx.compose.foundation.layout.fillMaxWidth |  | ||||||
| import androidx.compose.material.icons.Icons |  | ||||||
| import androidx.compose.material.icons.filled.Pause |  | ||||||
| import androidx.compose.material.icons.filled.PlayArrow |  | ||||||
| import androidx.compose.material3.Icon |  | ||||||
| import androidx.compose.material3.IconButton |  | ||||||
| import androidx.compose.material3.Slider |  | ||||||
| import androidx.compose.material3.Text |  | ||||||
| import androidx.compose.runtime.Composable |  | ||||||
| import androidx.compose.ui.Modifier |  | ||||||
| import androidx.compose.ui.graphics.Color |  | ||||||
| import androidx.compose.ui.platform.LocalContext |  | ||||||
| import androidx.compose.ui.unit.dp |  | ||||||
| import androidx.compose.ui.viewinterop.AndroidView |  | ||||||
| import androidx.lifecycle.viewmodel.compose.viewModel |  | ||||||
| import androidx.media3.exoplayer.ExoPlayer |  | ||||||
| import androidx.media3.ui.PlayerView |  | ||||||
| import androidx.navigation.NavHostController |  | ||||||
| import com.acitelight.aether.viewModel.VideoPlayerViewModel |  | ||||||
|  |  | ||||||
| import androidx.compose.foundation.gestures.detectDragGestures |  | ||||||
| import androidx.compose.foundation.gestures.detectTapGestures |  | ||||||
| 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.fillMaxHeight |  | ||||||
| import androidx.compose.foundation.layout.fillMaxSize |  | ||||||
| import androidx.compose.foundation.layout.height |  | ||||||
| import androidx.compose.foundation.layout.heightIn |  | ||||||
| import androidx.compose.foundation.layout.padding |  | ||||||
| import androidx.compose.foundation.layout.size |  | ||||||
| import androidx.compose.foundation.layout.width |  | ||||||
| import androidx.compose.foundation.lazy.LazyColumn |  | ||||||
| import androidx.compose.foundation.lazy.LazyRow |  | ||||||
| import androidx.compose.foundation.lazy.items |  | ||||||
| import androidx.compose.foundation.lazy.rememberLazyListState |  | ||||||
| import androidx.compose.foundation.shape.RoundedCornerShape |  | ||||||
| import androidx.compose.material.icons.automirrored.filled.ArrowBack |  | ||||||
| import androidx.compose.material.icons.filled.FastForward |  | ||||||
| import androidx.compose.material.icons.filled.Fullscreen |  | ||||||
| import androidx.compose.material.icons.filled.Info |  | ||||||
| import androidx.compose.material.icons.filled.Share |  | ||||||
| import androidx.compose.material.icons.filled.Star |  | ||||||
| import androidx.compose.material.icons.filled.ThumbDown |  | ||||||
| import androidx.compose.material.icons.filled.ThumbUp |  | ||||||
| import androidx.compose.material3.Card |  | ||||||
| import androidx.compose.material3.CardColors |  | ||||||
| import androidx.compose.material3.DividerDefaults |  | ||||||
| import androidx.compose.material3.ExperimentalMaterial3Api |  | ||||||
| import androidx.compose.material3.HorizontalDivider |  | ||||||
| import androidx.compose.material3.MaterialTheme |  | ||||||
| import androidx.compose.material3.VerticalDivider |  | ||||||
| import androidx.compose.material3.SliderDefaults |  | ||||||
| import androidx.compose.material3.Tab |  | ||||||
| import androidx.compose.material3.TabRow |  | ||||||
| import androidx.compose.runtime.getValue |  | ||||||
| import androidx.compose.runtime.mutableFloatStateOf |  | ||||||
| import androidx.compose.runtime.mutableStateOf |  | ||||||
| import androidx.compose.runtime.remember |  | ||||||
| import androidx.compose.runtime.setValue |  | ||||||
| import androidx.compose.ui.Alignment |  | ||||||
| import androidx.compose.ui.draw.alpha |  | ||||||
| import androidx.compose.ui.draw.clip |  | ||||||
| import androidx.compose.ui.geometry.Offset |  | ||||||
| import androidx.compose.ui.graphics.Brush |  | ||||||
| import androidx.compose.ui.input.nestedscroll.NestedScrollConnection |  | ||||||
| import androidx.compose.ui.input.nestedscroll.NestedScrollSource |  | ||||||
| import androidx.compose.ui.input.nestedscroll.nestedScroll |  | ||||||
| import androidx.compose.ui.input.pointer.pointerInput |  | ||||||
| import androidx.compose.ui.layout.ContentScale |  | ||||||
| import androidx.compose.ui.layout.onGloballyPositioned |  | ||||||
| import androidx.compose.ui.platform.LocalConfiguration |  | ||||||
| import androidx.compose.ui.platform.LocalDensity |  | ||||||
| import androidx.compose.ui.text.font.FontWeight |  | ||||||
| import androidx.compose.ui.unit.sp |  | ||||||
| import coil3.ImageLoader |  | ||||||
| import coil3.compose.AsyncImage |  | ||||||
| import coil3.request.ImageRequest |  | ||||||
| import com.acitelight.aether.Global |  | ||||||
| import com.acitelight.aether.ToggleFullScreen |  | ||||||
| import com.acitelight.aether.model.KeyImage |  | ||||||
| import com.acitelight.aether.model.Video |  | ||||||
|  |  | ||||||
| fun formatTime(ms: Long): String { |  | ||||||
|     if (ms <= 0) return "00:00:00" |  | ||||||
|     val totalSeconds = ms / 1000 |  | ||||||
|     val hours = totalSeconds / 3600 |  | ||||||
|     val minutes = (totalSeconds % 3600) / 60 |  | ||||||
|     val seconds = totalSeconds % 60 |  | ||||||
|     return String.format("%02d:%02d:%02d", hours, minutes, seconds) |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun isLandscape(): Boolean { |  | ||||||
|     val configuration = LocalConfiguration.current |  | ||||||
|     return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @OptIn(ExperimentalMaterial3Api::class) |  | ||||||
| @Composable |  | ||||||
| fun BiliStyleSlider( |  | ||||||
|     modifier: Modifier = Modifier, |  | ||||||
|     value: Float, |  | ||||||
|     onValueChange: (Float) -> Unit, |  | ||||||
|     valueRange: ClosedFloatingPointRange<Float> = 0f..1f |  | ||||||
| ) { |  | ||||||
|     val thumbRadius = 6.dp |  | ||||||
|     val trackHeight = 3.dp |  | ||||||
|  |  | ||||||
|     Slider( |  | ||||||
|         value = value, |  | ||||||
|         onValueChange = onValueChange, |  | ||||||
|         valueRange = valueRange, |  | ||||||
|         modifier = modifier, |  | ||||||
|         colors = SliderDefaults.colors( |  | ||||||
|             thumbColor = Color(0xFFFFFFFF),  // B站粉色 |  | ||||||
|             activeTrackColor = Color(0xFFFF6699), |  | ||||||
|             inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) |  | ||||||
|         ), |  | ||||||
|  |  | ||||||
|         track = { sliderPositions -> |  | ||||||
|             Box( |  | ||||||
|                 Modifier |  | ||||||
|                     .height(trackHeight) |  | ||||||
|                     .fillMaxWidth() |  | ||||||
|                     .background(Color.LightGray.copy(alpha = 0.3f), RoundedCornerShape(50)) |  | ||||||
|             ) { |  | ||||||
|                 Box( |  | ||||||
|                     Modifier |  | ||||||
|                         .align(Alignment.CenterStart) |  | ||||||
|                         .fillMaxWidth(value) |  | ||||||
|                         .fillMaxHeight() |  | ||||||
|                         .background(Color(0xFFFF6699), RoundedCornerShape(50)) |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     ) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @OptIn(ExperimentalMaterial3Api::class) |  | ||||||
| @Composable |  | ||||||
| fun BiliMiniSlider( |  | ||||||
|     modifier: Modifier = Modifier, |  | ||||||
|     value: Float, |  | ||||||
|     onValueChange: (Float) -> Unit, |  | ||||||
|     valueRange: ClosedFloatingPointRange<Float> = 0f..1f |  | ||||||
| ) { |  | ||||||
|     val thumbRadius = 6.dp |  | ||||||
|     val trackHeight = 3.dp |  | ||||||
|  |  | ||||||
|     Slider( |  | ||||||
|         value = value, |  | ||||||
|         onValueChange = onValueChange, |  | ||||||
|         valueRange = valueRange, |  | ||||||
|         modifier = modifier, |  | ||||||
|         colors = SliderDefaults.colors( |  | ||||||
|             thumbColor = Color(0xFFFFFFFF),  // B站粉色 |  | ||||||
|             activeTrackColor = Color(0xFFFF6699), |  | ||||||
|             inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) |  | ||||||
|         ), |  | ||||||
|         thumb = { |  | ||||||
|  |  | ||||||
|         }, |  | ||||||
|         track = { sliderPositions -> |  | ||||||
|             Box( |  | ||||||
|                 Modifier |  | ||||||
|                     .height(trackHeight) |  | ||||||
|                     .fillMaxWidth() |  | ||||||
|                     .background(Color.LightGray.copy(alpha = 0.3f), RoundedCornerShape(50)) |  | ||||||
|             ) { |  | ||||||
|                 Box( |  | ||||||
|                     Modifier |  | ||||||
|                         .align(Alignment.CenterStart) |  | ||||||
|                         .fillMaxWidth(value) |  | ||||||
|                         .fillMaxHeight() |  | ||||||
|                         .background(Color(0xFFFF6699), RoundedCornerShape(50)) |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     ) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun VideoPlayer( |  | ||||||
|     videoPlayerViewModel: VideoPlayerViewModel = viewModel(), |  | ||||||
|     videoId: String, |  | ||||||
|     navController: NavHostController |  | ||||||
| ) { |  | ||||||
|     videoPlayerViewModel.Init(videoId) |  | ||||||
|  |  | ||||||
|     if(videoPlayerViewModel.startPlaying) |  | ||||||
|     { |  | ||||||
|         if (isLandscape()) { |  | ||||||
|             VideoPlayerLandscape(videoPlayerViewModel) |  | ||||||
|         } |  | ||||||
|         else |  | ||||||
|         { |  | ||||||
|             VideoPlayerPortal(videoPlayerViewModel, navController) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel, cover: Float) |  | ||||||
| { |  | ||||||
|     val exoPlayer: ExoPlayer = videoPlayerViewModel._player!!; |  | ||||||
|     val context = LocalContext.current |  | ||||||
|     val activity = context as? Activity |  | ||||||
|  |  | ||||||
|     Box(modifier) |  | ||||||
|     { |  | ||||||
|         AndroidView( |  | ||||||
|             factory = { |  | ||||||
|                 PlayerView( |  | ||||||
|                     it |  | ||||||
|                 ).apply { |  | ||||||
|                     player = exoPlayer |  | ||||||
|                     useController = false |  | ||||||
|                 } |  | ||||||
|             }, |  | ||||||
|             modifier = Modifier |  | ||||||
|                 .align(Alignment.TopCenter) |  | ||||||
|                 .fillMaxWidth() |  | ||||||
|                 .pointerInput(Unit) { |  | ||||||
|                     detectDragGestures( |  | ||||||
|                         onDragStart = { |  | ||||||
|                             videoPlayerViewModel.dragging = true |  | ||||||
|                             videoPlayerViewModel.planeVisibility = true |  | ||||||
|                             exoPlayer.pause() |  | ||||||
|                         }, |  | ||||||
|                         onDragEnd = { |  | ||||||
|                             videoPlayerViewModel.dragging = false |  | ||||||
|                             if (videoPlayerViewModel.isPlaying) |  | ||||||
|                                 exoPlayer.play() |  | ||||||
|                         }, |  | ||||||
|                         onDrag = { change, dragAmount -> |  | ||||||
|                             exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong()) |  | ||||||
|                             videoPlayerViewModel.playProcess = exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat() |  | ||||||
|                         } |  | ||||||
|                     ) |  | ||||||
|                 } |  | ||||||
|                 .pointerInput(Unit) { |  | ||||||
|                     detectTapGestures( |  | ||||||
|                         onDoubleTap = { |  | ||||||
|                             videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying |  | ||||||
|                             if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() |  | ||||||
|                         }, |  | ||||||
|                         onTap = { |  | ||||||
|                             videoPlayerViewModel.planeVisibility = |  | ||||||
|                                 !videoPlayerViewModel.planeVisibility |  | ||||||
|                         }, |  | ||||||
|                         onLongPress = { |  | ||||||
|                             videoPlayerViewModel.isLongPressing = true |  | ||||||
|                             exoPlayer.playbackParameters = exoPlayer.playbackParameters |  | ||||||
|                                 .withSpeed(3.0f) |  | ||||||
|                         }, |  | ||||||
|                         onPress = { offset -> |  | ||||||
|                             val pressResult = tryAwaitRelease() |  | ||||||
|                             if (pressResult && videoPlayerViewModel.isLongPressing) { |  | ||||||
|                                 videoPlayerViewModel.isLongPressing = false |  | ||||||
|                                 exoPlayer.playbackParameters = exoPlayer.playbackParameters |  | ||||||
|                                     .withSpeed(1.0f) |  | ||||||
|                             } |  | ||||||
|                         }, |  | ||||||
|                     ) |  | ||||||
|                 } |  | ||||||
|         ) |  | ||||||
|  |  | ||||||
|         androidx.compose.animation.AnimatedVisibility( |  | ||||||
|             visible = videoPlayerViewModel.isLongPressing, |  | ||||||
|             enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }), |  | ||||||
|             exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }), |  | ||||||
|             modifier = Modifier |  | ||||||
|                 .align(Alignment.TopCenter) |  | ||||||
|         ) |  | ||||||
|         { |  | ||||||
|             Box(modifier = Modifier.align(Alignment.TopCenter).padding(top = 24.dp).background(Color(0x44000000), RoundedCornerShape(18))) |  | ||||||
|             { |  | ||||||
|                 Row{ |  | ||||||
|                     Icon( |  | ||||||
|                         imageVector = Icons.Filled.FastForward, |  | ||||||
|                         contentDescription = "Fast Forward", |  | ||||||
|                         tint = Color.White, |  | ||||||
|                         modifier = Modifier.size(36.dp).padding(4.dp).align(Alignment.CenterVertically) |  | ||||||
|                     ) |  | ||||||
|  |  | ||||||
|                     Text( |  | ||||||
|                         text = "3X Speed...", |  | ||||||
|                         modifier = Modifier.padding(4.dp).align(Alignment.CenterVertically), |  | ||||||
|                         fontSize = 16.sp, |  | ||||||
|                         fontWeight = FontWeight.Bold, |  | ||||||
|                         color = Color(0xFFFFFFFF) |  | ||||||
|                     ) |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         androidx.compose.animation.AnimatedVisibility( |  | ||||||
|             visible = videoPlayerViewModel.dragging, |  | ||||||
|             enter = fadeIn( |  | ||||||
|                 initialAlpha = 0f, |  | ||||||
|             ), |  | ||||||
|             exit = fadeOut( |  | ||||||
|                 targetAlpha = 0f |  | ||||||
|             ), |  | ||||||
|             modifier = Modifier.align(Alignment.Center) |  | ||||||
|         ) { |  | ||||||
|             Text( |  | ||||||
|                 text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${ |  | ||||||
|                     formatTime( |  | ||||||
|                         (exoPlayer.duration).toLong() |  | ||||||
|                     ) |  | ||||||
|                 }", |  | ||||||
|                 fontWeight = FontWeight.Bold, |  | ||||||
|                 modifier = Modifier |  | ||||||
|                     .padding(bottom = 12.dp), |  | ||||||
|                 fontSize = 18.sp |  | ||||||
|             ) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if(cover > 0.0f) |  | ||||||
|             Spacer(Modifier.background(Color(0x00FF6699 - 0x00222222 + ((0x000000FF * cover).toLong() shl 24) )).fillMaxSize()) |  | ||||||
|  |  | ||||||
|         androidx.compose.animation.AnimatedVisibility( |  | ||||||
|             visible = !videoPlayerViewModel.planeVisibility, |  | ||||||
|             enter = fadeIn( |  | ||||||
|                 initialAlpha = 0f, |  | ||||||
|             ), |  | ||||||
|             exit = fadeOut( |  | ||||||
|                 targetAlpha = 0f |  | ||||||
|             ), |  | ||||||
|             modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter) |  | ||||||
|         ) { |  | ||||||
|             BiliMiniSlider( |  | ||||||
|                 value = videoPlayerViewModel.playProcess, |  | ||||||
|                 onValueChange = {}, |  | ||||||
|                 modifier = Modifier |  | ||||||
|                     .height(4.dp) |  | ||||||
|                     .align(Alignment.BottomCenter) |  | ||||||
|             ) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         androidx.compose.animation.AnimatedVisibility( |  | ||||||
|             visible = videoPlayerViewModel.planeVisibility, |  | ||||||
|             enter = fadeIn( |  | ||||||
|                 initialAlpha = 0f, |  | ||||||
|             ), |  | ||||||
|             exit = fadeOut( |  | ||||||
|                 targetAlpha = 0f |  | ||||||
|             ), |  | ||||||
|             modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).height(42.dp) |  | ||||||
|         ) |  | ||||||
|         { |  | ||||||
|             Row( |  | ||||||
|                 modifier = Modifier |  | ||||||
|                     .fillMaxWidth() |  | ||||||
|                     .padding(horizontal = 2.dp) |  | ||||||
|                     .align(Alignment.BottomCenter), |  | ||||||
|                 horizontalArrangement = Arrangement.SpaceBetween, |  | ||||||
|             ) { |  | ||||||
|                 IconButton( |  | ||||||
|                     onClick = { |  | ||||||
|                         videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying |  | ||||||
|                         if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() |  | ||||||
|                     }, |  | ||||||
|                     Modifier |  | ||||||
|                         .size(36.dp) |  | ||||||
|                         .align(Alignment.CenterVertically) |  | ||||||
|                 ) { |  | ||||||
|                     Icon( |  | ||||||
|                         imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, |  | ||||||
|                         contentDescription = "Play/Pause", |  | ||||||
|                         tint = Color.White, |  | ||||||
|                         modifier = Modifier.size(32.dp) |  | ||||||
|                     ) |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 BiliStyleSlider( |  | ||||||
|                     value = videoPlayerViewModel.playProcess, |  | ||||||
|                     onValueChange = { value -> |  | ||||||
|                         exoPlayer.seekTo((exoPlayer.duration * value).toLong()) |  | ||||||
|                     }, |  | ||||||
|                     modifier = Modifier |  | ||||||
|                         .height(8.dp) |  | ||||||
|                         .align(Alignment.CenterVertically) |  | ||||||
|                         .weight(1f) |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|                 Text( |  | ||||||
|                     text = formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong()), |  | ||||||
|                     maxLines = 1, |  | ||||||
|                     fontSize = 12.sp, |  | ||||||
|                     color = Color(0xFFFFFFFF), |  | ||||||
|                     fontWeight = FontWeight.Bold, |  | ||||||
|                     modifier = Modifier |  | ||||||
|                         .width(80.dp) |  | ||||||
|                         .align(Alignment.CenterVertically) |  | ||||||
|                         .padding(start = 12.dp) |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|                 IconButton( |  | ||||||
|                     onClick = { |  | ||||||
|                         activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE |  | ||||||
|                     }, |  | ||||||
|                     Modifier |  | ||||||
|                         .size(36.dp) |  | ||||||
|                         .align(Alignment.CenterVertically) |  | ||||||
|                 ) { |  | ||||||
|                     Icon( |  | ||||||
|                         imageVector = Icons.Default.Fullscreen, |  | ||||||
|                         contentDescription = "Fullscreen", |  | ||||||
|                         tint = Color.White, |  | ||||||
|                         modifier = Modifier.size(32.dp) |  | ||||||
|                     ) |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun VideoPlayerPortal(videoPlayerViewModel: VideoPlayerViewModel, navController: NavHostController) |  | ||||||
| { |  | ||||||
|     val configuration = LocalConfiguration.current |  | ||||||
|     val screenHeight = configuration.screenHeightDp.dp; |  | ||||||
|  |  | ||||||
|     val minHeight = 42.dp |  | ||||||
|     var coverAlpha by remember{ mutableFloatStateOf(0.0f) } |  | ||||||
|     var maxHeight = remember { screenHeight * 0.65f } |  | ||||||
|     var posed = remember { false } |  | ||||||
|     val dens = LocalDensity.current |  | ||||||
|     val listState = rememberLazyListState() |  | ||||||
|  |  | ||||||
|     var playerHeight by remember { mutableStateOf(screenHeight * 0.65f) } |  | ||||||
|  |  | ||||||
|     val nestedScrollConnection = remember { |  | ||||||
|         object : NestedScrollConnection { |  | ||||||
|             override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { |  | ||||||
|                 val deltaY = available.y // px |  | ||||||
|                 val deltaDp = with(dens) { deltaY.toDp() } |  | ||||||
|  |  | ||||||
|                 val r = if (deltaY < 0 && playerHeight > minHeight) { |  | ||||||
|                     val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight) |  | ||||||
|                     val consumedDp = newHeight - playerHeight |  | ||||||
|                     playerHeight = newHeight |  | ||||||
|                     val consumedPx = with(dens) { consumedDp.toPx() } |  | ||||||
|                     Offset(0f, consumedPx) |  | ||||||
|                 } else if(deltaY > 0 && playerHeight < maxHeight && listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0) { |  | ||||||
|                     val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight) |  | ||||||
|                     val consumedDp = newHeight - playerHeight |  | ||||||
|                     playerHeight = newHeight |  | ||||||
|                     val consumedPx = with(dens) { consumedDp.toPx() } |  | ||||||
|                     Offset(0f, consumedPx) |  | ||||||
|                 } else { |  | ||||||
|                     Offset.Zero |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 val dh = playerHeight - minHeight; |  | ||||||
|                 coverAlpha = (if(dh > 10.dp) |  | ||||||
|                     0f |  | ||||||
|                 else |  | ||||||
|                     (10.dp.value - dh.value) / 10.0f) |  | ||||||
|  |  | ||||||
|                 return r |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     ToggleFullScreen(false) |  | ||||||
|     Column(Modifier.nestedScroll(nestedScrollConnection).fillMaxHeight()) |  | ||||||
|     { |  | ||||||
|         PortalCorePlayer( |  | ||||||
|             Modifier |  | ||||||
|                 .padding(top = 42.dp) |  | ||||||
|                 .heightIn(max = playerHeight) |  | ||||||
|                 .onGloballyPositioned { layoutCoordinates -> |  | ||||||
|                     if(!posed && videoPlayerViewModel.renderedFirst) |  | ||||||
|                     { |  | ||||||
|                         maxHeight = with(dens) {layoutCoordinates.size.height.toDp()} |  | ||||||
|                         playerHeight = maxHeight |  | ||||||
|                         posed = true |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|             videoPlayerViewModel = videoPlayerViewModel, coverAlpha) |  | ||||||
|  |  | ||||||
|         Row() |  | ||||||
|         { |  | ||||||
|             TabRow ( |  | ||||||
|                 selectedTabIndex = videoPlayerViewModel.tabIndex, |  | ||||||
|                 modifier = Modifier.height(38.dp).fillMaxWidth(0.6f) |  | ||||||
|             ) { |  | ||||||
|                 Tab( |  | ||||||
|                     selected = videoPlayerViewModel.tabIndex == 0, |  | ||||||
|                     onClick = { videoPlayerViewModel.tabIndex = 0  }, |  | ||||||
|                     text = { Text(text = "Introduction", maxLines = 1) }, |  | ||||||
|                     modifier = Modifier.height(38.dp) |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|                 Tab( |  | ||||||
|                     selected = videoPlayerViewModel.tabIndex == 1, |  | ||||||
|                     onClick = { videoPlayerViewModel.tabIndex = 1  }, |  | ||||||
|                     text = { Text(text = "Comment", maxLines = 1) }, |  | ||||||
|                     modifier = Modifier.height(38.dp) |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         LazyColumn(state = listState, modifier = Modifier.fillMaxWidth()) { |  | ||||||
|             item{ |  | ||||||
|                 HorizontalDivider(Modifier, 2.dp, DividerDefaults.color) |  | ||||||
|  |  | ||||||
|                 Text( |  | ||||||
|                     modifier = Modifier.align(Alignment.Start).padding(horizontal = 12.dp).padding(top = 12.dp), |  | ||||||
|                     text = videoPlayerViewModel.video?.video?.name ?: "", |  | ||||||
|                     fontSize = 16.sp, |  | ||||||
|                     maxLines = 2, |  | ||||||
|                     fontWeight = FontWeight.Bold, |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|                 Row(Modifier.align(Alignment.Start).padding(horizontal = 4.dp).alpha(0.5f)) { |  | ||||||
|                     Text( |  | ||||||
|                         modifier = Modifier.padding(horizontal = 8.dp), |  | ||||||
|                         text = videoPlayerViewModel.video?.klass ?: "", |  | ||||||
|                         fontSize = 14.sp, |  | ||||||
|                         maxLines = 1, |  | ||||||
|                         fontWeight = FontWeight.Bold, |  | ||||||
|                     ) |  | ||||||
|  |  | ||||||
|                     Text( |  | ||||||
|                         modifier = Modifier.padding(horizontal = 8.dp), |  | ||||||
|                         text = formatTime(videoPlayerViewModel.video?.video?.duration ?: 0), |  | ||||||
|                         fontSize = 14.sp, |  | ||||||
|                         maxLines = 1, |  | ||||||
|                         fontWeight = FontWeight.Bold, |  | ||||||
|                     ) |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|                 HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color) |  | ||||||
|  |  | ||||||
|                 SocialPanel(Modifier.align(Alignment.CenterHorizontally).fillMaxWidth(), videoPlayerViewModel = videoPlayerViewModel) |  | ||||||
|  |  | ||||||
|                 HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color) |  | ||||||
|  |  | ||||||
|                 HorizontalGallery(videoPlayerViewModel) |  | ||||||
|                 HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color) |  | ||||||
|  |  | ||||||
|                 for(i in Global.sameClassVideos ?: listOf()) |  | ||||||
|                 { |  | ||||||
|                     if(i.id == videoPlayerViewModel.video?.id) continue |  | ||||||
|  |  | ||||||
|                     MiniVideoCard( |  | ||||||
|                         modifier = Modifier |  | ||||||
|                             .padding(horizontal = 12.dp), |  | ||||||
|                         i, |  | ||||||
|                         { |  | ||||||
|                             videoPlayerViewModel.isPlaying = false |  | ||||||
|                             videoPlayerViewModel._player?.pause() |  | ||||||
|                             val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }" |  | ||||||
|                             navController.navigate(route) |  | ||||||
|                         }, videoPlayerViewModel.imageLoader!!) |  | ||||||
|                     HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color) |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun SocialPanel(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel) |  | ||||||
| { |  | ||||||
|     Row( |  | ||||||
|         modifier, |  | ||||||
|         horizontalArrangement = Arrangement.Center |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         Column(modifier = Modifier.padding(horizontal = 12.dp)) { |  | ||||||
|             IconButton( |  | ||||||
|                 onClick = {  }, |  | ||||||
|                 modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp), |  | ||||||
|             ) { |  | ||||||
|                 Icon( |  | ||||||
|                     modifier = Modifier.size(28.dp), |  | ||||||
|                     imageVector = Icons.Filled.ThumbUp, |  | ||||||
|                     contentDescription = "ThumbUp", |  | ||||||
|                     tint = Color.Gray |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             Text( |  | ||||||
|                 modifier = Modifier.align(Alignment.CenterHorizontally), |  | ||||||
|                 text = videoPlayerViewModel.thumbUp.toString(), |  | ||||||
|                 fontSize = 12.sp, |  | ||||||
|                 maxLines = 1, |  | ||||||
|                 fontWeight = FontWeight.Bold) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         Column(modifier = Modifier.padding(horizontal = 12.dp)) { |  | ||||||
|             IconButton( |  | ||||||
|                 onClick = {  }, |  | ||||||
|                 modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp), |  | ||||||
|             ) { |  | ||||||
|                 Icon( |  | ||||||
|                     modifier = Modifier.size(28.dp), |  | ||||||
|                     imageVector = Icons.Filled.ThumbDown, |  | ||||||
|                     contentDescription = "ThumbDown", |  | ||||||
|                     tint = Color.Gray |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             Text( |  | ||||||
|                 modifier = Modifier.align(Alignment.CenterHorizontally), |  | ||||||
|                 text = videoPlayerViewModel.thumbDown.toString(), |  | ||||||
|                 fontSize = 12.sp, |  | ||||||
|                 maxLines = 1, |  | ||||||
|                 fontWeight = FontWeight.Bold) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         Column(modifier = Modifier.padding(horizontal = 12.dp)) { |  | ||||||
|             IconButton( |  | ||||||
|                 onClick = { videoPlayerViewModel.star = !videoPlayerViewModel.star  }, |  | ||||||
|                 modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp), |  | ||||||
|             ) { |  | ||||||
|                 Icon( |  | ||||||
|                     modifier = Modifier.size(28.dp), |  | ||||||
|                     imageVector = Icons.Filled.Star, |  | ||||||
|                     contentDescription = "Star", |  | ||||||
|                     tint = if(videoPlayerViewModel.star) Color(0xFFFF6699) else Color.Gray |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         Column(modifier = Modifier.padding(horizontal = 12.dp)) { |  | ||||||
|             IconButton( |  | ||||||
|                 onClick = { }, |  | ||||||
|                 modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp), |  | ||||||
|             ) { |  | ||||||
|                 Icon( |  | ||||||
|                     modifier = Modifier.size(28.dp), |  | ||||||
|                     imageVector = Icons.Filled.Share, |  | ||||||
|                     contentDescription = "Forward", |  | ||||||
|                     tint = Color.Gray |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         Column(modifier = Modifier.padding(horizontal = 12.dp)) { |  | ||||||
|             IconButton( |  | ||||||
|                 onClick = { }, |  | ||||||
|                 modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp), |  | ||||||
|             ) { |  | ||||||
|                 Icon( |  | ||||||
|                     modifier = Modifier.size(28.dp), |  | ||||||
|                     imageVector = Icons.Filled.Info, |  | ||||||
|                     contentDescription = "Detail", |  | ||||||
|                     tint = Color.Gray |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun HorizontalGallery(videoPlayerViewModel: VideoPlayerViewModel) |  | ||||||
| { |  | ||||||
|     LazyRow( |  | ||||||
|         modifier = Modifier.fillMaxWidth().height(120.dp), |  | ||||||
|         horizontalArrangement = Arrangement.spacedBy(12.dp), |  | ||||||
|         contentPadding = PaddingValues(horizontal = 24.dp) |  | ||||||
|     ) { |  | ||||||
|         items(videoPlayerViewModel.video?.getGallery() ?: listOf()) { it -> |  | ||||||
|             SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun SingleImageItem(img: KeyImage, imageLoader: ImageLoader) { |  | ||||||
|     AsyncImage( |  | ||||||
|         model = ImageRequest.Builder(LocalContext.current) |  | ||||||
|             .data(img.url) |  | ||||||
|             .memoryCacheKey(img.key) |  | ||||||
|             .diskCacheKey(img.key) |  | ||||||
|             .build(), |  | ||||||
|         contentDescription = null, |  | ||||||
|         modifier = Modifier |  | ||||||
|             .fillMaxWidth() |  | ||||||
|             .clip(RoundedCornerShape(12.dp)), |  | ||||||
|         contentScale = ContentScale.Crop, |  | ||||||
|         imageLoader = imageLoader |  | ||||||
|     ) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel) |  | ||||||
| { |  | ||||||
|     val context = LocalContext.current |  | ||||||
|     val activity = context as? Activity |  | ||||||
|     val exoPlayer: ExoPlayer = videoPlayerViewModel._player!!; |  | ||||||
|  |  | ||||||
|     BackHandler { |  | ||||||
|         activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     ToggleFullScreen(true) |  | ||||||
|     Box(Modifier.fillMaxSize()) |  | ||||||
|     { |  | ||||||
|         Box( |  | ||||||
|             modifier = Modifier |  | ||||||
|                 .background(Color.Black).align(Alignment.Center) |  | ||||||
|         ) |  | ||||||
|         { |  | ||||||
|             AndroidView( |  | ||||||
|                 factory = { |  | ||||||
|                     PlayerView( |  | ||||||
|                         it |  | ||||||
|                     ).apply { |  | ||||||
|                         player = exoPlayer |  | ||||||
|                         useController = false |  | ||||||
|                     } |  | ||||||
|                 }, |  | ||||||
|                 modifier = Modifier |  | ||||||
|                     .fillMaxWidth() |  | ||||||
|                     .pointerInput(Unit) { |  | ||||||
|                         detectDragGestures( |  | ||||||
|                             onDragStart = { |  | ||||||
|                                 videoPlayerViewModel.planeVisibility = true |  | ||||||
|                                 videoPlayerViewModel.dragging = true; |  | ||||||
|                                 exoPlayer.pause() |  | ||||||
|                             }, |  | ||||||
|                             onDragEnd = { |  | ||||||
|                                 videoPlayerViewModel.dragging = false; |  | ||||||
|                                 if (videoPlayerViewModel.isPlaying) |  | ||||||
|                                     exoPlayer.play() |  | ||||||
|                             }, |  | ||||||
|                             onDrag = { change, dragAmount -> |  | ||||||
|                                 exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong()) |  | ||||||
|                                 videoPlayerViewModel.playProcess = exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat() |  | ||||||
|                             } |  | ||||||
|                         ) |  | ||||||
|                     } |  | ||||||
|                     .pointerInput(Unit) { |  | ||||||
|                         detectTapGestures( |  | ||||||
|                             onDoubleTap = { |  | ||||||
|                                 videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying |  | ||||||
|                                 if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() |  | ||||||
|                             }, |  | ||||||
|                             onTap = { |  | ||||||
|                                 videoPlayerViewModel.planeVisibility = |  | ||||||
|                                     !videoPlayerViewModel.planeVisibility |  | ||||||
|                             }, |  | ||||||
|                             onLongPress = { |  | ||||||
|                                 videoPlayerViewModel.isLongPressing = true |  | ||||||
|                                 exoPlayer.playbackParameters = exoPlayer.playbackParameters |  | ||||||
|                                     .withSpeed(3.0f) |  | ||||||
|                             }, |  | ||||||
|                             onPress = { offset -> |  | ||||||
|                                 val pressResult = tryAwaitRelease() |  | ||||||
|                                 if (pressResult && videoPlayerViewModel.isLongPressing) { |  | ||||||
|                                     videoPlayerViewModel.isLongPressing = false |  | ||||||
|                                     exoPlayer.playbackParameters = exoPlayer.playbackParameters |  | ||||||
|                                         .withSpeed(1.0f) |  | ||||||
|                                 } |  | ||||||
|                             }, |  | ||||||
|                         ) |  | ||||||
|                     } |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|             androidx.compose.animation.AnimatedVisibility( |  | ||||||
|                 visible = videoPlayerViewModel.dragging, |  | ||||||
|                 enter = fadeIn( |  | ||||||
|                     initialAlpha = 0f, |  | ||||||
|                 ), |  | ||||||
|                 exit = fadeOut( |  | ||||||
|                     targetAlpha = 0f |  | ||||||
|                 ), |  | ||||||
|                 modifier = Modifier.align(Alignment.Center) |  | ||||||
|             ) { |  | ||||||
|                 Text( |  | ||||||
|                     text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${ |  | ||||||
|                         formatTime( |  | ||||||
|                             (exoPlayer.duration).toLong() |  | ||||||
|                         ) |  | ||||||
|                     }", |  | ||||||
|                     fontWeight = FontWeight.Bold, |  | ||||||
|                     modifier = Modifier.padding(bottom = 12.dp), |  | ||||||
|                     fontSize = 18.sp |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             AnimatedVisibility( |  | ||||||
|                 visible = videoPlayerViewModel.isLongPressing, |  | ||||||
|                 enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }), |  | ||||||
|                 exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }), |  | ||||||
|                 modifier = Modifier |  | ||||||
|                     .align(Alignment.TopCenter) |  | ||||||
|             ) |  | ||||||
|             { |  | ||||||
|                 Box(modifier = Modifier.align(Alignment.TopCenter).padding(top = 24.dp).background(Color(0x44000000), RoundedCornerShape(18))) |  | ||||||
|                 { |  | ||||||
|                     Row{ |  | ||||||
|                         Icon( |  | ||||||
|                             imageVector = Icons.Filled.FastForward, |  | ||||||
|                             contentDescription = "Fast Forward", |  | ||||||
|                             tint = Color.White, |  | ||||||
|                             modifier = Modifier.size(36.dp).padding(4.dp).align(Alignment.CenterVertically) |  | ||||||
|                         ) |  | ||||||
|  |  | ||||||
|                         Text( |  | ||||||
|                             text = "3X Speed...", |  | ||||||
|                             modifier = Modifier.padding(4.dp).align(Alignment.CenterVertically), |  | ||||||
|                             fontSize = 16.sp, |  | ||||||
|                             fontWeight = FontWeight.Bold, |  | ||||||
|                             color = Color(0xFFFFFFFF) |  | ||||||
|                         ) |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             IconButton( |  | ||||||
|                 onClick = { |  | ||||||
|                     activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT |  | ||||||
|                 }, |  | ||||||
|                 modifier = Modifier |  | ||||||
|                     .align(Alignment.TopStart) |  | ||||||
|                     .padding(8.dp) |  | ||||||
|             ) { |  | ||||||
|                 Icon( |  | ||||||
|                     imageVector = Icons.AutoMirrored.Filled.ArrowBack, |  | ||||||
|                     contentDescription = "Back", |  | ||||||
|                     tint = Color.White |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             AnimatedVisibility( |  | ||||||
|                 visible = videoPlayerViewModel.planeVisibility, |  | ||||||
|                 enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }), |  | ||||||
|                 exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }), |  | ||||||
|                 modifier = Modifier |  | ||||||
|                     .align(Alignment.BottomCenter) |  | ||||||
|                     .fillMaxWidth() |  | ||||||
|             ) |  | ||||||
|             { |  | ||||||
|                 Column( |  | ||||||
|                     modifier = Modifier |  | ||||||
|                         .align(Alignment.BottomCenter) |  | ||||||
|                         .fillMaxWidth() |  | ||||||
|                         .background( brush = Brush.verticalGradient( |  | ||||||
|                             colors = listOf( |  | ||||||
|                                 Color.Transparent, |  | ||||||
|                                 Color.Black.copy(alpha = 0.4f) |  | ||||||
|                             ) |  | ||||||
|                         )) |  | ||||||
|                         .padding(horizontal = 36.dp) |  | ||||||
|                 ) { |  | ||||||
|                     Text( |  | ||||||
|                         text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${ |  | ||||||
|                             formatTime( |  | ||||||
|                                 (exoPlayer.duration).toLong() |  | ||||||
|                             ) |  | ||||||
|                         }", |  | ||||||
|                         fontWeight = FontWeight.Bold, |  | ||||||
|                         modifier = Modifier.padding(bottom = 12.dp), |  | ||||||
|                         fontSize = 12.sp |  | ||||||
|                     ) |  | ||||||
|                     BiliStyleSlider( |  | ||||||
|                         value = videoPlayerViewModel.playProcess, |  | ||||||
|                         onValueChange = { value -> |  | ||||||
|                             exoPlayer.seekTo((exoPlayer.duration * value).toLong()) |  | ||||||
|                         }, |  | ||||||
|                         modifier = Modifier.height(16.dp).fillMaxWidth().padding(bottom = 8.dp) |  | ||||||
|                     ) |  | ||||||
|                     Row( |  | ||||||
|                         modifier = Modifier |  | ||||||
|                             .fillMaxWidth() |  | ||||||
|                             .padding(bottom = 8.dp) |  | ||||||
|                             .align(Alignment.Start), |  | ||||||
|                         horizontalArrangement = Arrangement.SpaceBetween, |  | ||||||
|                     ) { |  | ||||||
|                         IconButton( |  | ||||||
|                             onClick = { |  | ||||||
|                                 videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying |  | ||||||
|                                 if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() |  | ||||||
|                             }, |  | ||||||
|                             Modifier.size(42.dp) |  | ||||||
|                         ) { |  | ||||||
|                             Icon( |  | ||||||
|                                 imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, |  | ||||||
|                                 contentDescription = "Play/Pause", |  | ||||||
|                                 tint = Color.White, |  | ||||||
|                                 modifier = Modifier.size(42.dp) |  | ||||||
|                             ) |  | ||||||
|                         } |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit, imageLoader: ImageLoader) |  | ||||||
| { |  | ||||||
|     var isImageLoaded by remember { mutableStateOf(false) } |  | ||||||
|     Card( |  | ||||||
|         modifier = modifier.height(80.dp).fillMaxWidth(), |  | ||||||
|         colors = CardColors( |  | ||||||
|             containerColor = Color.Transparent, |  | ||||||
|             contentColor = MaterialTheme.colorScheme.onSurface, |  | ||||||
|             disabledContentColor = Color.Transparent, |  | ||||||
|             disabledContainerColor = Color.Transparent |  | ||||||
|         ), |  | ||||||
|         onClick = onClick |  | ||||||
|     ) |  | ||||||
|     { |  | ||||||
|         Row() |  | ||||||
|         { |  | ||||||
|             AsyncImage( |  | ||||||
|                 model = ImageRequest.Builder(LocalContext.current) |  | ||||||
|                     .data(video.getCover()) |  | ||||||
|                     .memoryCacheKey("${video.klass}/${video.id}/cover") |  | ||||||
|                     .diskCacheKey("${video.klass}/${video.id}/cover") |  | ||||||
|                     .listener( |  | ||||||
|                         onStart = { }, |  | ||||||
|                         onSuccess = { _, _ -> isImageLoaded = true }, |  | ||||||
|                         onError = { _, _ ->  } |  | ||||||
|                     ) |  | ||||||
|                     .build(), |  | ||||||
|                 contentDescription = null, |  | ||||||
|                 modifier = Modifier |  | ||||||
|                     .width(128.dp).fillMaxHeight() |  | ||||||
|                     .clip(RoundedCornerShape(8.dp)), |  | ||||||
|                 contentScale = ContentScale.Crop, |  | ||||||
|                 imageLoader = imageLoader |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|             Column ( |  | ||||||
|                 modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight().fillMaxWidth().align(Alignment.CenterVertically), |  | ||||||
|                 verticalArrangement = Arrangement.Center |  | ||||||
|             ) |  | ||||||
|             { |  | ||||||
|                 Text( |  | ||||||
|                     modifier = Modifier, |  | ||||||
|                     text = video.video.name, |  | ||||||
|                     fontSize = 14.sp, |  | ||||||
|                     maxLines = 2, |  | ||||||
|                     fontWeight = FontWeight.Bold, |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|                 Spacer(modifier.weight(1f)) |  | ||||||
|  |  | ||||||
|                 Text( |  | ||||||
|                     modifier = Modifier.height(16.dp), |  | ||||||
|                     text = video.klass, |  | ||||||
|                     fontSize = 8.sp, |  | ||||||
|                     maxLines = 1, |  | ||||||
|                     fontWeight = FontWeight.Bold, |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|                 Text( |  | ||||||
|                     modifier = Modifier.height(16.dp), |  | ||||||
|                     text = formatTime(video.video.duration), |  | ||||||
|                     fontSize = 8.sp, |  | ||||||
|                     maxLines = 1, |  | ||||||
|                     fontWeight = FontWeight.Bold, |  | ||||||
|                 ) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,175 +0,0 @@ | |||||||
| package com.acitelight.aether.view |  | ||||||
|  |  | ||||||
| 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.heightIn |  | ||||||
| import androidx.compose.foundation.layout.padding |  | ||||||
| import androidx.compose.foundation.layout.wrapContentHeight |  | ||||||
| import androidx.compose.foundation.lazy.grid.GridCells |  | ||||||
| import androidx.compose.foundation.lazy.grid.LazyVerticalGrid |  | ||||||
| import androidx.compose.foundation.lazy.grid.items |  | ||||||
| import androidx.compose.foundation.shape.RoundedCornerShape |  | ||||||
| import androidx.compose.material3.Card |  | ||||||
| import androidx.compose.material3.ExperimentalMaterial3Api |  | ||||||
| import androidx.compose.material3.Tab |  | ||||||
| import androidx.compose.material3.Text |  | ||||||
| import androidx.compose.runtime.Composable |  | ||||||
| import androidx.compose.runtime.collectAsState |  | ||||||
| import androidx.compose.runtime.getValue |  | ||||||
| import androidx.compose.ui.Modifier |  | ||||||
| import androidx.compose.ui.graphics.Color |  | ||||||
| import androidx.compose.ui.layout.ContentScale |  | ||||||
| import androidx.compose.ui.text.font.FontWeight |  | ||||||
| import androidx.compose.ui.unit.dp |  | ||||||
| import androidx.compose.ui.unit.sp |  | ||||||
| import androidx.lifecycle.viewmodel.compose.viewModel |  | ||||||
| import coil3.compose.AsyncImage |  | ||||||
| import com.acitelight.aether.model.Video |  | ||||||
| import com.acitelight.aether.viewModel.VideoScreenViewModel |  | ||||||
| import androidx.compose.material3.ScrollableTabRow |  | ||||||
| import androidx.compose.runtime.mutableStateListOf |  | ||||||
| import androidx.compose.runtime.mutableStateOf |  | ||||||
| import androidx.compose.ui.Alignment |  | ||||||
| import androidx.compose.ui.graphics.Brush |  | ||||||
| import androidx.compose.ui.platform.LocalContext |  | ||||||
| import androidx.navigation.NavHostController |  | ||||||
| import coil3.request.ImageRequest |  | ||||||
| import com.acitelight.aether.Global |  | ||||||
| import java.nio.charset.Charset |  | ||||||
|  |  | ||||||
| fun String.toHex(): String { |  | ||||||
|     return this.toByteArray().joinToString("") { "%02x".format(it) } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| fun String.hexToString(charset: Charset = Charsets.UTF_8): String { |  | ||||||
|     require(length % 2 == 0) { "Hex string must have even length" } |  | ||||||
|  |  | ||||||
|     val bytes = ByteArray(length / 2) |  | ||||||
|     for (i in bytes.indices) { |  | ||||||
|         val hexByte = substring(i * 2, i * 2 + 2) |  | ||||||
|         bytes[i] = hexByte.toInt(16).toByte() |  | ||||||
|     } |  | ||||||
|     return String(bytes, charset) |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navController: NavHostController) |  | ||||||
| { |  | ||||||
|     val tabIndex by videoScreenViewModel.tabIndex; |  | ||||||
|     videoScreenViewModel.SetupClient() |  | ||||||
|  |  | ||||||
|     Column( |  | ||||||
|         modifier = Modifier.fillMaxSize() // 或至少 fillMaxWidth() |  | ||||||
|     ){ |  | ||||||
|         TopRow(videoScreenViewModel); |  | ||||||
|  |  | ||||||
|         LazyVerticalGrid( |  | ||||||
|             columns = GridCells.Fixed(2), |  | ||||||
|             contentPadding = PaddingValues(8.dp), |  | ||||||
|             verticalArrangement = Arrangement.spacedBy(8.dp), |  | ||||||
|             horizontalArrangement = Arrangement.spacedBy(8.dp) |  | ||||||
|         ) |  | ||||||
|         { |  | ||||||
|             if(videoScreenViewModel.classes.isNotEmpty()) |  | ||||||
|             { |  | ||||||
|                 items(videoScreenViewModel.classesMap[videoScreenViewModel.classes[tabIndex]] ?: mutableStateListOf()) { video -> |  | ||||||
|                     VideoCard(video, navController, videoScreenViewModel) |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @OptIn(ExperimentalMaterial3Api::class) |  | ||||||
| @Composable |  | ||||||
| fun TopRow(videoScreenViewModel: VideoScreenViewModel) |  | ||||||
| { |  | ||||||
|     val tabIndex by videoScreenViewModel.tabIndex; |  | ||||||
|  |  | ||||||
|     if(videoScreenViewModel.classes.isEmpty()) return |  | ||||||
|  |  | ||||||
|     ScrollableTabRow (selectedTabIndex = tabIndex) { |  | ||||||
|         videoScreenViewModel.classes.forEachIndexed { index, title -> |  | ||||||
|             Tab( |  | ||||||
|                 selected = tabIndex == index, |  | ||||||
|                 onClick = { videoScreenViewModel.setTabIndex(index)  }, |  | ||||||
|                 text = { Text(text = title, maxLines = 1) }, |  | ||||||
|             ) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @Composable |  | ||||||
| fun VideoCard(video: Video, navController: NavHostController, videoScreenViewModel: VideoScreenViewModel) { |  | ||||||
|     val tabIndex by videoScreenViewModel.tabIndex; |  | ||||||
|     Card( |  | ||||||
|         shape = RoundedCornerShape(6.dp), |  | ||||||
|         modifier = Modifier |  | ||||||
|             .fillMaxWidth() |  | ||||||
|             .wrapContentHeight(), |  | ||||||
|         onClick = { |  | ||||||
|             Global.sameClassVideos = videoScreenViewModel.classesMap[videoScreenViewModel.classes[tabIndex]] ?: mutableStateListOf() |  | ||||||
|             val route = "video_player_route/${ "${video.klass}/${video.id}".toHex() }" |  | ||||||
|             navController.navigate(route) |  | ||||||
|         } |  | ||||||
|     ) { |  | ||||||
|         Column( |  | ||||||
|             modifier = Modifier |  | ||||||
|                 .fillMaxWidth() |  | ||||||
|         )  { |  | ||||||
|             Box(modifier = Modifier.fillMaxSize()){ |  | ||||||
|                 AsyncImage( |  | ||||||
|                     model = ImageRequest.Builder(LocalContext.current) |  | ||||||
|                         .data(video.getCover()) |  | ||||||
|                         .memoryCacheKey("${video.klass}/${video.id}/cover") |  | ||||||
|                         .diskCacheKey("${video.klass}/${video.id}/cover") |  | ||||||
|                         .build(), |  | ||||||
|                     contentDescription = null, |  | ||||||
|                     modifier = Modifier |  | ||||||
|                         .fillMaxSize(), |  | ||||||
|                     contentScale = ContentScale.Crop, |  | ||||||
|                     imageLoader = videoScreenViewModel.imageLoader!! |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|                 Text( |  | ||||||
|                     modifier = Modifier.align(Alignment.BottomEnd).padding(2.dp), |  | ||||||
|                     text = formatTime(video.video.duration), fontSize = 12.sp, color = Color.White, fontWeight = FontWeight.Bold) |  | ||||||
|  |  | ||||||
|                 Box( |  | ||||||
|                     Modifier |  | ||||||
|                         .fillMaxWidth() |  | ||||||
|                         .height(24.dp) |  | ||||||
|                         .background( brush = Brush.verticalGradient( |  | ||||||
|                             colors = listOf( |  | ||||||
|                                 Color.Transparent, |  | ||||||
|                                 Color.Black.copy(alpha = 0.45f) |  | ||||||
|                             ) |  | ||||||
|                         )) |  | ||||||
|                         .align(Alignment.BottomCenter)) |  | ||||||
|             } |  | ||||||
|             Text( |  | ||||||
|                 text = video.video.name, |  | ||||||
|                 fontSize = 14.sp, |  | ||||||
|                 fontWeight = FontWeight.Bold, |  | ||||||
|                 maxLines = 2, |  | ||||||
|                 modifier = Modifier.padding(8.dp).background(Color.Transparent).heightIn(48.dp) |  | ||||||
|             ) |  | ||||||
|             Spacer(modifier = Modifier.weight(1f)) |  | ||||||
|             Row( |  | ||||||
|                 modifier = Modifier.padding(horizontal = 8.dp), |  | ||||||
|                 horizontalArrangement = Arrangement.SpaceBetween, |  | ||||||
|             ) { |  | ||||||
|                 Text("Class", fontSize = 12.sp) |  | ||||||
|                 Text("${video.klass}", fontSize = 12.sp) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -0,0 +1,103 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.ExperimentalMaterial3Api | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Slider | ||||||
|  | import androidx.compose.material3.SliderColors | ||||||
|  | import androidx.compose.material3.SliderDefaults | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  |  | ||||||
|  | @OptIn(ExperimentalMaterial3Api::class) | ||||||
|  | @Composable | ||||||
|  | fun BiliStyleSlider( | ||||||
|  |     modifier: Modifier = Modifier, | ||||||
|  |     value: Float, | ||||||
|  |     onValueChange: (Float) -> Unit, | ||||||
|  |     valueRange: ClosedFloatingPointRange<Float> = 0f..1f | ||||||
|  | ) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     val trackHeight = 3.dp | ||||||
|  |  | ||||||
|  |     Slider( | ||||||
|  |         value = value, | ||||||
|  |         onValueChange = onValueChange, | ||||||
|  |         valueRange = valueRange, | ||||||
|  |         modifier = modifier, | ||||||
|  |         colors = SliderDefaults.colors( | ||||||
|  |             thumbColor = Color(0xFFFFFFFF), | ||||||
|  |             activeTrackColor = colorScheme.primary, | ||||||
|  |             inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |         ), | ||||||
|  |  | ||||||
|  |         track = { sliderPositions -> | ||||||
|  |             Box( | ||||||
|  |                 Modifier | ||||||
|  |                     .height(trackHeight) | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .background(Color.LightGray.copy(alpha = 0.3f), RoundedCornerShape(50)) | ||||||
|  |             ) { | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .align(Alignment.CenterStart) | ||||||
|  |                         .fillMaxWidth(value) | ||||||
|  |                         .fillMaxHeight() | ||||||
|  |                         .background(colorScheme.primary, RoundedCornerShape(50)) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @OptIn(ExperimentalMaterial3Api::class) | ||||||
|  | @Composable | ||||||
|  | fun BiliMiniSlider( | ||||||
|  |     modifier: Modifier = Modifier, | ||||||
|  |     value: Float, | ||||||
|  |     onValueChange: (Float) -> Unit, | ||||||
|  |     valueRange: ClosedFloatingPointRange<Float> = 0f..1f, | ||||||
|  |     colors: SliderColors = SliderDefaults.colors( | ||||||
|  |         thumbColor = Color(0xFFFFFFFF), | ||||||
|  |         activeTrackColor = MaterialTheme.colorScheme.primary, | ||||||
|  |         inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |     ) | ||||||
|  | ) { | ||||||
|  |     val trackHeight = 3.dp | ||||||
|  |  | ||||||
|  |     Slider( | ||||||
|  |         value = value, | ||||||
|  |         onValueChange = onValueChange, | ||||||
|  |         valueRange = valueRange, | ||||||
|  |         modifier = modifier, | ||||||
|  |         colors = colors, | ||||||
|  |         enabled = false, | ||||||
|  |         thumb = { | ||||||
|  |  | ||||||
|  |         }, | ||||||
|  |         track = { sliderPositions -> | ||||||
|  |             Box( | ||||||
|  |                 Modifier | ||||||
|  |                     .height(trackHeight) | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .background(colors.inactiveTrackColor, RoundedCornerShape(50)) | ||||||
|  |             ) { | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .align(Alignment.CenterStart) | ||||||
|  |                         .fillMaxWidth(value) | ||||||
|  |                         .fillMaxHeight() | ||||||
|  |                         .background(colors.activeTrackColor, RoundedCornerShape(50)) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -0,0 +1,60 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.material3.AlertDialog | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.OutlinedTextField | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.material3.TextButton | ||||||
|  | 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.window.DialogProperties | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun BookmarkPop( | ||||||
|  |     onDismiss: () -> Unit, | ||||||
|  |     onConfirm: (String) -> Unit | ||||||
|  | ) | ||||||
|  | { | ||||||
|  |     var inputValue by remember { mutableStateOf("") } | ||||||
|  |  | ||||||
|  |     AlertDialog( | ||||||
|  |         onDismissRequest = onDismiss, | ||||||
|  |         title = { | ||||||
|  |             Text("Bookmark", style = MaterialTheme.typography.headlineMedium) | ||||||
|  |         }, | ||||||
|  |         text = { | ||||||
|  |             Column { | ||||||
|  |                 OutlinedTextField( | ||||||
|  |                     value = inputValue, | ||||||
|  |                     onValueChange = { inputValue = it }, | ||||||
|  |                     label = { Text("Bookmark") }, | ||||||
|  |                     modifier = Modifier.fillMaxWidth(), | ||||||
|  |                     singleLine = true | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         confirmButton = { | ||||||
|  |             TextButton( | ||||||
|  |                 onClick = { onConfirm(inputValue) }, | ||||||
|  |                 enabled = inputValue.isNotBlank() | ||||||
|  |             ) { | ||||||
|  |                 Text("Confirm") | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         dismissButton = { | ||||||
|  |             TextButton(onClick = onDismiss) { | ||||||
|  |                 Text("Cancel") | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         properties = DialogProperties( | ||||||
|  |             dismissOnBackPress = true, | ||||||
|  |             dismissOnClickOutside = true | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -0,0 +1,120 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | 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.Brush | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.Comic | ||||||
|  | import com.acitelight.aether.view.pages.toHex | ||||||
|  | import com.acitelight.aether.viewModel.ComicScreenViewModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun ComicCard( | ||||||
|  |     comic: Comic, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     comicScreenViewModel: ComicScreenViewModel | ||||||
|  | ) { | ||||||
|  |     Card( | ||||||
|  |         colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(0.65f)), | ||||||
|  |         shape = RoundedCornerShape(8.dp), | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .wrapContentHeight(), | ||||||
|  |         onClick = { | ||||||
|  |             val route = "comic_grid_route/${comic.id.toHex()}" | ||||||
|  |             navController.navigate(route) | ||||||
|  |         } | ||||||
|  |     ) { | ||||||
|  |         Column( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |         ) { | ||||||
|  |             Box(modifier = Modifier.fillMaxSize()) { | ||||||
|  |                 AsyncImage( | ||||||
|  |                     model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                         .data(comic.getCover(comicScreenViewModel.apiClient)) | ||||||
|  |                         .memoryCacheKey("${comic.id}/cover") | ||||||
|  |                         .diskCacheKey("${comic.id}/cover") | ||||||
|  |                         .build(), | ||||||
|  |                     contentDescription = null, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .fillMaxSize() | ||||||
|  |                         .clip(RoundedCornerShape(8.dp)), | ||||||
|  |                     contentScale = ContentScale.Fit, | ||||||
|  |                     imageLoader = comicScreenViewModel.imageLoader!!, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .height(24.dp) | ||||||
|  |                         .background( | ||||||
|  |                             brush = Brush.verticalGradient( | ||||||
|  |                                 colors = listOf( | ||||||
|  |                                     Color.Transparent, | ||||||
|  |                                     Color.Black.copy(alpha = 0.45f) | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                         .align(Alignment.BottomCenter) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .align(Alignment.BottomEnd) | ||||||
|  |                             .padding(2.dp), | ||||||
|  |                         fontSize = 12.sp, | ||||||
|  |                         text = "${comic.comic.list.size} Pages", | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         color = Color.White, | ||||||
|  |                         maxLines = 1 | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             Text( | ||||||
|  |                 text = comic.comic.comic_name, | ||||||
|  |                 fontSize = 12.sp, | ||||||
|  |                 lineHeight = 14.sp, | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 maxLines = 2, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(4.dp) | ||||||
|  |                     .heightIn(min = 14.dp) | ||||||
|  |             ) | ||||||
|  |             Text( | ||||||
|  |                 text = "Id: ${comic.id}", | ||||||
|  |                 fontSize = 10.sp, | ||||||
|  |                 lineHeight = 12.sp, | ||||||
|  |                 maxLines = 1, | ||||||
|  |                 modifier = Modifier.padding(4.dp) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,55 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.PaddingValues | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.lazy.LazyRow | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.KeyImage | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun HorizontalGallery(videoPlayerViewModel: VideoPlayerViewModel) { | ||||||
|  |     val gallery by videoPlayerViewModel.currentGallery | ||||||
|  |     LazyRow( | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .height(120.dp), | ||||||
|  |         horizontalArrangement = Arrangement.spacedBy(12.dp), | ||||||
|  |         contentPadding = PaddingValues(horizontal = 24.dp) | ||||||
|  |     ) { | ||||||
|  |         items(gallery) { it -> | ||||||
|  |             SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | private fun SingleImageItem(img: KeyImage, imageLoader: ImageLoader) { | ||||||
|  |     AsyncImage( | ||||||
|  |         model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |             .data(img.url) | ||||||
|  |             .memoryCacheKey(img.key) | ||||||
|  |             .diskCacheKey(img.key) | ||||||
|  |             .build(), | ||||||
|  |         contentDescription = null, | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .clip(RoundedCornerShape(12.dp)), | ||||||
|  |         contentScale = ContentScale.Crop, | ||||||
|  |         imageLoader = imageLoader | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -0,0 +1,136 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | 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.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardColors | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | 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.draw.drawWithContent | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.graphics.drawOutline | ||||||
|  | import androidx.compose.ui.graphics.drawscope.Stroke | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.service.ApiClient | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun MiniPlaylistCard(modifier: Modifier, video: Video, imageLoader: ImageLoader, selected: Boolean, apiClient: ApiClient, onClick: () -> Unit) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     Card( | ||||||
|  |         modifier = modifier | ||||||
|  |             .height(80.dp) | ||||||
|  |             .fillMaxWidth(), | ||||||
|  |         colors = CardColors( | ||||||
|  |             containerColor = Color.Transparent, | ||||||
|  |             contentColor = MaterialTheme.colorScheme.onSurface, | ||||||
|  |             disabledContentColor = Color.Transparent, | ||||||
|  |             disabledContainerColor = Color.Transparent | ||||||
|  |         ), | ||||||
|  |         onClick = onClick | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         Row() | ||||||
|  |         { | ||||||
|  |             AsyncImage( | ||||||
|  |                 model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                     .data(video.getCover(apiClient)) | ||||||
|  |                     .memoryCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                     .diskCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                     .listener( | ||||||
|  |                         onStart = { }, | ||||||
|  |                         onError = { _, _ -> } | ||||||
|  |                     ) | ||||||
|  |                     .build(), | ||||||
|  |                 contentDescription = null, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .width(128.dp) | ||||||
|  |                     .fillMaxHeight() | ||||||
|  |                     .clip(RoundedCornerShape(8.dp)) | ||||||
|  |                     .then( | ||||||
|  |                         if (selected) | ||||||
|  |                             Modifier.drawWithContent { | ||||||
|  |                                 drawContent() | ||||||
|  |  | ||||||
|  |                                 val strokeWidth = 3.dp.toPx() | ||||||
|  |                                 val shape = RoundedCornerShape(8.dp) | ||||||
|  |                                 val outline = shape.createOutline(size, layoutDirection, this) | ||||||
|  |  | ||||||
|  |                                 drawOutline( | ||||||
|  |                                     outline = outline, | ||||||
|  |                                     color = colorScheme.primary, | ||||||
|  |                                     style = Stroke(width = strokeWidth) | ||||||
|  |                                 ) | ||||||
|  |                             } | ||||||
|  |                         else | ||||||
|  |                             Modifier | ||||||
|  |                     ), | ||||||
|  |                 contentScale = ContentScale.Crop, | ||||||
|  |                 imageLoader = imageLoader | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             Column( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(horizontal = 8.dp) | ||||||
|  |                     .fillMaxHeight() | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .align(Alignment.CenterVertically), | ||||||
|  |                 verticalArrangement = Arrangement.Center, | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier, | ||||||
|  |                     text = video.video.name, | ||||||
|  |                     fontSize = 13.sp, | ||||||
|  |                     maxLines = 2, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     lineHeight = 14.sp, | ||||||
|  |                     color = if(selected) colorScheme.primary else colorScheme.onSurface | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Spacer(modifier.weight(1f)) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier.height(16.dp), | ||||||
|  |                     text = video.klass, | ||||||
|  |                     fontSize = 8.sp, | ||||||
|  |                     lineHeight = 9.sp, | ||||||
|  |                     maxLines = 1, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     color = if(selected) colorScheme.primary else colorScheme.onSurface | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier.height(16.dp), | ||||||
|  |                     text = formatTime(video.video.duration), | ||||||
|  |                     fontSize = 8.sp, | ||||||
|  |                     lineHeight = 9.sp, | ||||||
|  |                     maxLines = 1, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     color = if(selected) colorScheme.primary else colorScheme.onSurface | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,108 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | 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.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardColors | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | 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.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.service.ApiClient | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun MiniVideoCard(modifier: Modifier, video: Video, imageLoader: ImageLoader, apiClient: ApiClient, onClick: () -> Unit) { | ||||||
|  |     Card( | ||||||
|  |         modifier = modifier | ||||||
|  |             .height(80.dp) | ||||||
|  |             .fillMaxWidth(), | ||||||
|  |         colors = CardColors( | ||||||
|  |             containerColor = Color.Transparent, | ||||||
|  |             contentColor = MaterialTheme.colorScheme.onSurface, | ||||||
|  |             disabledContentColor = Color.Transparent, | ||||||
|  |             disabledContainerColor = Color.Transparent | ||||||
|  |         ), | ||||||
|  |         onClick = onClick | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         Row() | ||||||
|  |         { | ||||||
|  |             AsyncImage( | ||||||
|  |                 model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                     .data(video.getCover(apiClient)) | ||||||
|  |                     .memoryCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                     .diskCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                     .listener( | ||||||
|  |                         onStart = { }, | ||||||
|  |                         onError = { _, _ -> } | ||||||
|  |                     ) | ||||||
|  |                     .build(), | ||||||
|  |                 contentDescription = null, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .width(128.dp) | ||||||
|  |                     .fillMaxHeight() | ||||||
|  |                     .clip(RoundedCornerShape(8.dp)), | ||||||
|  |                 contentScale = ContentScale.Crop, | ||||||
|  |                 imageLoader = imageLoader | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             Column( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(horizontal = 8.dp) | ||||||
|  |                     .fillMaxHeight() | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .align(Alignment.CenterVertically), | ||||||
|  |                 verticalArrangement = Arrangement.Center | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier, | ||||||
|  |                     text = video.video.name, | ||||||
|  |                     fontSize = 14.sp, | ||||||
|  |                     maxLines = 2, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Spacer(modifier.weight(1f)) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier.height(16.dp), | ||||||
|  |                     text = video.klass, | ||||||
|  |                     fontSize = 8.sp, | ||||||
|  |                     maxLines = 1, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier.height(16.dp), | ||||||
|  |                     text = formatTime(video.video.duration), | ||||||
|  |                     fontSize = 8.sp, | ||||||
|  |                     maxLines = 1, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,92 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.PaddingValues | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.lazy.LazyRow | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.foundation.lazy.rememberLazyListState | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.LaunchedEffect | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | 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.compose.ui.unit.sp | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun PlaylistPanel(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     val name by videoPlayerViewModel.currentName | ||||||
|  |     val id by videoPlayerViewModel.currentId | ||||||
|  |  | ||||||
|  |     val listState = rememberLazyListState() | ||||||
|  |     val videos = videoPlayerViewModel.videos | ||||||
|  |  | ||||||
|  |     LaunchedEffect(id, videos) { | ||||||
|  |         val targetIndex = videos.indexOfFirst { it.id == id } | ||||||
|  |         if (targetIndex >= 0) { | ||||||
|  |             listState.scrollToItem(targetIndex) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     LazyRow( | ||||||
|  |         modifier = modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .height(80.dp), | ||||||
|  |         state = listState, | ||||||
|  |         horizontalArrangement = Arrangement.spacedBy(6.dp), | ||||||
|  |         contentPadding = PaddingValues(horizontal = 24.dp) | ||||||
|  |     ) { | ||||||
|  |         items(videos) { it -> | ||||||
|  |             Card( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxHeight() | ||||||
|  |                     .width(140.dp), | ||||||
|  |                 onClick = { | ||||||
|  |                     if (name == it.video.name) | ||||||
|  |                         return@Card | ||||||
|  |  | ||||||
|  |                     videoPlayerViewModel.viewModelScope.launch { | ||||||
|  |                         videoPlayerViewModel.startPlay(it) | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 colors = | ||||||
|  |                     if (it.id == id) | ||||||
|  |                         CardDefaults.cardColors(containerColor = colorScheme.primary) | ||||||
|  |                     else | ||||||
|  |                         CardDefaults.cardColors() | ||||||
|  |             ) { | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .fillMaxSize() | ||||||
|  |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier.align(Alignment.Center), | ||||||
|  |                         text = it.video.name, | ||||||
|  |                         maxLines = 4, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         fontSize = 12.sp, | ||||||
|  |                         lineHeight = 13.sp | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,430 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import android.app.Activity | ||||||
|  | import android.content.Context | ||||||
|  | import android.media.AudioManager | ||||||
|  | import android.view.View | ||||||
|  | import androidx.annotation.OptIn | ||||||
|  | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | 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.gestures.detectDragGestures | ||||||
|  | import androidx.compose.foundation.gestures.detectTapGestures | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | 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.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.automirrored.filled.VolumeUp | ||||||
|  | import androidx.compose.material.icons.filled.Brightness4 | ||||||
|  | import androidx.compose.material.icons.filled.FastForward | ||||||
|  | import androidx.compose.material.icons.filled.Fullscreen | ||||||
|  | import androidx.compose.material.icons.filled.Pause | ||||||
|  | import androidx.compose.material.icons.filled.PlayArrow | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.IconButton | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableFloatStateOf | ||||||
|  | import androidx.compose.runtime.remember | ||||||
|  | import androidx.compose.runtime.setValue | ||||||
|  | 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.input.pointer.pointerInput | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.compose.ui.viewinterop.AndroidView | ||||||
|  | import androidx.media3.common.util.UnstableApi | ||||||
|  | import androidx.media3.exoplayer.ExoPlayer | ||||||
|  | import androidx.media3.ui.PlayerView | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  | import com.acitelight.aether.view.pages.moveBrit | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  | import kotlin.math.abs | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @OptIn(UnstableApi::class) | ||||||
|  | @Composable | ||||||
|  | fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel, cover: Float) { | ||||||
|  |     val exoPlayer: ExoPlayer = videoPlayerViewModel.player!! | ||||||
|  |     val context = LocalContext.current | ||||||
|  |     val activity = (context as? Activity)!! | ||||||
|  |  | ||||||
|  |     val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } | ||||||
|  |     val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } | ||||||
|  |     var volFactor by remember { | ||||||
|  |         mutableFloatStateOf( | ||||||
|  |             audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume.toFloat() | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun setVolume(value: Int) { | ||||||
|  |         audioManager.setStreamVolume( | ||||||
|  |             AudioManager.STREAM_MUSIC, | ||||||
|  |             value.coerceIn(0, maxVolume), | ||||||
|  |             AudioManager.FLAG_PLAY_SOUND | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Box(modifier) | ||||||
|  |     { | ||||||
|  |         AndroidView( | ||||||
|  |             factory = { | ||||||
|  |                 PlayerView( | ||||||
|  |                     it | ||||||
|  |                 ).apply { | ||||||
|  |                     player = exoPlayer | ||||||
|  |                     useController = false | ||||||
|  |                     subtitleView?.let { sv -> | ||||||
|  |                         sv.visibility = View.GONE | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .align(Alignment.TopCenter) | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |                 .pointerInput(Unit) { | ||||||
|  |                     detectDragGestures( | ||||||
|  |                         onDragStart = { offset -> | ||||||
|  |                             if (videoPlayerViewModel.locked) return@detectDragGestures | ||||||
|  |                             if (offset.x < size.width / 2) { | ||||||
|  |                                 videoPlayerViewModel.draggingPurpose = -1 | ||||||
|  |                             } else { | ||||||
|  |                                 videoPlayerViewModel.draggingPurpose = -2 | ||||||
|  |                             } | ||||||
|  |                         }, | ||||||
|  |                         onDragEnd = { | ||||||
|  |                             if (videoPlayerViewModel.isPlaying && videoPlayerViewModel.draggingPurpose == 0) | ||||||
|  |                                 exoPlayer.play() | ||||||
|  |  | ||||||
|  |                             videoPlayerViewModel.draggingPurpose = -1 | ||||||
|  |                         }, | ||||||
|  |                         onDrag = { change, dragAmount -> | ||||||
|  |                             if (videoPlayerViewModel.locked) return@detectDragGestures | ||||||
|  |                             if (abs(dragAmount.x) > abs(dragAmount.y) && | ||||||
|  |                                 (videoPlayerViewModel.draggingPurpose == -1 || videoPlayerViewModel.draggingPurpose == -2) | ||||||
|  |                             ) { | ||||||
|  |                                 videoPlayerViewModel.draggingPurpose = 0 | ||||||
|  |                                 videoPlayerViewModel.planeVisibility = true | ||||||
|  |                                 exoPlayer.pause() | ||||||
|  |                             } else if (videoPlayerViewModel.draggingPurpose == -1) videoPlayerViewModel.draggingPurpose = | ||||||
|  |                                 1 | ||||||
|  |                             else if (videoPlayerViewModel.draggingPurpose == -2) videoPlayerViewModel.draggingPurpose = | ||||||
|  |                                 2 | ||||||
|  |  | ||||||
|  |                             if (videoPlayerViewModel.draggingPurpose == 0) { | ||||||
|  |                                 exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong()) | ||||||
|  |                                 videoPlayerViewModel.playProcess = | ||||||
|  |                                     exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat() | ||||||
|  |                             } else if (videoPlayerViewModel.draggingPurpose == 2) { | ||||||
|  |                                 val cu = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) | ||||||
|  |                                 volFactor = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) | ||||||
|  |                                     .toFloat() / maxVolume.toFloat() | ||||||
|  |                                 if (dragAmount.y < 0) | ||||||
|  |                                     setVolume(cu + 1) | ||||||
|  |                                 else if (dragAmount.y > 0) | ||||||
|  |                                     setVolume(cu - 1) | ||||||
|  |                             } else if (videoPlayerViewModel.draggingPurpose == 1) { | ||||||
|  |                                 moveBrit(dragAmount.y, activity, videoPlayerViewModel) | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                         } | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |                 .pointerInput(Unit) { | ||||||
|  |                     detectTapGestures( | ||||||
|  |                         onDoubleTap = { | ||||||
|  |                             if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |                             videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying | ||||||
|  |                             if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() | ||||||
|  |                         }, | ||||||
|  |                         onTap = { | ||||||
|  |                             if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |                             videoPlayerViewModel.planeVisibility = | ||||||
|  |                                 !videoPlayerViewModel.planeVisibility | ||||||
|  |                         }, | ||||||
|  |                         onLongPress = { | ||||||
|  |                             if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |                             videoPlayerViewModel.isLongPressing = true | ||||||
|  |                             exoPlayer.playbackParameters = exoPlayer.playbackParameters | ||||||
|  |                                 .withSpeed(3.0f) | ||||||
|  |                         }, | ||||||
|  |                         onPress = { offset -> | ||||||
|  |                             val pressResult = tryAwaitRelease() | ||||||
|  |                             if (pressResult && videoPlayerViewModel.isLongPressing) { | ||||||
|  |                                 videoPlayerViewModel.isLongPressing = false | ||||||
|  |                                 exoPlayer.playbackParameters = exoPlayer.playbackParameters | ||||||
|  |                                     .withSpeed(1.0f) | ||||||
|  |                             } | ||||||
|  |                         }, | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = videoPlayerViewModel.isLongPressing, | ||||||
|  |             enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |             exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .align(Alignment.TopCenter) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Box( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.TopCenter) | ||||||
|  |                     .padding(top = 24.dp) | ||||||
|  |                     .background(Color(0x44000000), RoundedCornerShape(18)) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Row { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = Icons.Filled.FastForward, | ||||||
|  |                         contentDescription = "Fast Forward", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .size(36.dp) | ||||||
|  |                             .padding(4.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     Text( | ||||||
|  |                         text = "3X Speed...", | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(4.dp) | ||||||
|  |                             .align(Alignment.CenterVertically), | ||||||
|  |                         fontSize = 16.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         color = Color(0xFFFFFFFF) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = videoPlayerViewModel.draggingPurpose == 0, | ||||||
|  |             enter = fadeIn( | ||||||
|  |                 initialAlpha = 0f, | ||||||
|  |             ), | ||||||
|  |             exit = fadeOut( | ||||||
|  |                 targetAlpha = 0f | ||||||
|  |             ), | ||||||
|  |             modifier = Modifier.align(Alignment.Center) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Text( | ||||||
|  |                 text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${ | ||||||
|  |                     formatTime( | ||||||
|  |                         (exoPlayer.duration) | ||||||
|  |                     ) | ||||||
|  |                 }", | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 modifier = Modifier.padding(bottom = 12.dp), | ||||||
|  |                 fontSize = 18.sp | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = videoPlayerViewModel.draggingPurpose == 2, | ||||||
|  |             enter = fadeIn( | ||||||
|  |                 initialAlpha = 0f, | ||||||
|  |             ), | ||||||
|  |             exit = fadeOut( | ||||||
|  |                 targetAlpha = 0f | ||||||
|  |             ), | ||||||
|  |             modifier = Modifier.align(Alignment.Center) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Row(Modifier | ||||||
|  |                 .background(Color(0x88000000), RoundedCornerShape(18)) | ||||||
|  |                 .width(200.dp)) | ||||||
|  |             { | ||||||
|  |                 Icon( | ||||||
|  |                     imageVector = Icons.AutoMirrored.Filled.VolumeUp, | ||||||
|  |                     contentDescription = "Vol", | ||||||
|  |                     tint = Color.White, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .size(48.dp) | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) | ||||||
|  |                 BiliMiniSlider( | ||||||
|  |                     value = volFactor, | ||||||
|  |                     onValueChange = {}, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .height(4.dp) | ||||||
|  |                         .padding(horizontal = 8.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = videoPlayerViewModel.draggingPurpose == 1, | ||||||
|  |             enter = fadeIn( | ||||||
|  |                 initialAlpha = 0f, | ||||||
|  |             ), | ||||||
|  |             exit = fadeOut( | ||||||
|  |                 targetAlpha = 0f | ||||||
|  |             ), | ||||||
|  |             modifier = Modifier.align(Alignment.Center) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Row(Modifier | ||||||
|  |                 .background(Color(0x88000000), RoundedCornerShape(18)) | ||||||
|  |                 .width(200.dp)) | ||||||
|  |             { | ||||||
|  |                 Icon( | ||||||
|  |                     imageVector = Icons.Default.Brightness4, | ||||||
|  |                     contentDescription = "Brightness", | ||||||
|  |                     tint = Color.White, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .size(48.dp) | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) | ||||||
|  |                 BiliMiniSlider( | ||||||
|  |                     value = videoPlayerViewModel.brit, | ||||||
|  |                     onValueChange = {}, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .height(4.dp) | ||||||
|  |                         .padding(horizontal = 8.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (cover > 0.0f) | ||||||
|  |             Spacer(Modifier | ||||||
|  |                 .background(MaterialTheme.colorScheme.primary.copy(cover)) | ||||||
|  |                 .fillMaxSize()) | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = (!videoPlayerViewModel.planeVisibility) || videoPlayerViewModel.locked, | ||||||
|  |             enter = fadeIn( | ||||||
|  |                 initialAlpha = 0f, | ||||||
|  |             ), | ||||||
|  |             exit = fadeOut( | ||||||
|  |                 targetAlpha = 0f | ||||||
|  |             ), | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |                 .align(Alignment.BottomCenter) | ||||||
|  |         ) { | ||||||
|  |             BiliMiniSlider( | ||||||
|  |                 value = videoPlayerViewModel.playProcess, | ||||||
|  |                 onValueChange = {}, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .height(4.dp) | ||||||
|  |                     .align(Alignment.BottomCenter) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = videoPlayerViewModel.planeVisibility && (!videoPlayerViewModel.locked), | ||||||
|  |             enter = fadeIn( | ||||||
|  |                 initialAlpha = 0f, | ||||||
|  |             ), | ||||||
|  |             exit = fadeOut( | ||||||
|  |                 targetAlpha = 0f | ||||||
|  |             ), | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |                 .align(Alignment.BottomCenter) | ||||||
|  |                 .height(42.dp) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Row( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .align(Alignment.BottomCenter) | ||||||
|  |                     .background( | ||||||
|  |                         brush = Brush.verticalGradient( | ||||||
|  |                             colors = listOf( | ||||||
|  |                                 Color.Transparent, | ||||||
|  |                                 Color.Black.copy(alpha = 0.4f), | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                 horizontalArrangement = Arrangement.SpaceBetween, | ||||||
|  |             ) { | ||||||
|  |                 IconButton( | ||||||
|  |                     onClick = { | ||||||
|  |                         videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying | ||||||
|  |                         if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() | ||||||
|  |                     }, | ||||||
|  |                     Modifier | ||||||
|  |                         .size(36.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, | ||||||
|  |                         contentDescription = "Play/Pause", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier.size(32.dp) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 BiliStyleSlider( | ||||||
|  |                     value = videoPlayerViewModel.playProcess, | ||||||
|  |                     onValueChange = { value -> | ||||||
|  |                         exoPlayer.seekTo((exoPlayer.duration * value).toLong()) | ||||||
|  |                     }, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .height(8.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                         .weight(1f) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     text = formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong()), | ||||||
|  |                     maxLines = 1, | ||||||
|  |                     fontSize = 12.sp, | ||||||
|  |                     color = Color(0xFFFFFFFF), | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .width(80.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                         .padding(start = 12.dp) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 IconButton( | ||||||
|  |                     onClick = { | ||||||
|  |                         videoPlayerViewModel.isLandscape = true | ||||||
|  |                     }, | ||||||
|  |                     Modifier | ||||||
|  |                         .size(36.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) { | ||||||
|  |                     Icon( | ||||||
|  |                         Icons.Default.Fullscreen, | ||||||
|  |                         contentDescription = "FullScreen", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier.size(32.dp) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         SubtitleOverlay( | ||||||
|  |             cues = videoPlayerViewModel.cues, | ||||||
|  |             modifier = Modifier.matchParentSize() | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| @@ -0,0 +1,84 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import android.text.Layout | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.wrapContentWidth | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | 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.geometry.Offset | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.graphics.Shadow | ||||||
|  | import androidx.compose.ui.platform.LocalDensity | ||||||
|  | import androidx.compose.ui.text.TextStyle | ||||||
|  | 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.TextUnit | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.media3.common.text.Cue | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun SubtitleOverlay( | ||||||
|  |     cues: List<Cue>, | ||||||
|  |     modifier: Modifier = Modifier, | ||||||
|  |     maxLines: Int = 2, | ||||||
|  |     textSize: TextUnit = 14.sp, | ||||||
|  |     backgroundAlpha: Float = 0.6f, | ||||||
|  |     horizontalMargin: Dp = 16.dp, | ||||||
|  |     bottomMargin: Dp = 14.dp, | ||||||
|  |     contentPadding: Dp = 6.dp, | ||||||
|  |     cornerRadius: Dp = 6.dp, | ||||||
|  |     textColor: Color = Color.White | ||||||
|  | ) { | ||||||
|  |     val raw = if (cues.isEmpty()) "" else cues.joinToString(separator = "\n") { | ||||||
|  |         it.text?.toString() ?: "" | ||||||
|  |     }.trim() | ||||||
|  |     if (raw.isEmpty()) return | ||||||
|  |  | ||||||
|  |     val textAlign = when (cues.firstOrNull()?.textAlignment) { | ||||||
|  |         Layout.Alignment.ALIGN_CENTER -> TextAlign.Center | ||||||
|  |         Layout.Alignment.ALIGN_OPPOSITE -> TextAlign.End | ||||||
|  |         Layout.Alignment.ALIGN_NORMAL -> TextAlign.Start | ||||||
|  |         else -> TextAlign.Center | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val blurPx = with(LocalDensity.current) { (2.dp).toPx() } | ||||||
|  |  | ||||||
|  |     Box( | ||||||
|  |         modifier = modifier, | ||||||
|  |         contentAlignment = Alignment.BottomCenter | ||||||
|  |     ) { | ||||||
|  |         Box( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .padding(start = horizontalMargin, end = horizontalMargin, bottom = bottomMargin) | ||||||
|  |                 .wrapContentWidth(Alignment.CenterHorizontally) | ||||||
|  |                 .clip(RoundedCornerShape(cornerRadius)) | ||||||
|  |                 .background(Color.Black.copy(alpha = backgroundAlpha)) | ||||||
|  |                 .padding(horizontal = 12.dp, vertical = contentPadding) | ||||||
|  |         ) { | ||||||
|  |             Text( | ||||||
|  |                 text = raw, | ||||||
|  |                 maxLines = maxLines, | ||||||
|  |                 overflow = TextOverflow.Ellipsis, | ||||||
|  |                 style = TextStyle( | ||||||
|  |                     color = textColor, | ||||||
|  |                     fontSize = textSize, | ||||||
|  |                     shadow = Shadow( | ||||||
|  |                         color = Color.Black.copy(alpha = 0.85f), | ||||||
|  |                         offset = Offset(0f, 0f), | ||||||
|  |                         blurRadius = blurPx | ||||||
|  |                     ) | ||||||
|  |                 ), | ||||||
|  |                 textAlign = textAlign, | ||||||
|  |                 modifier = Modifier | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,186 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import android.widget.Toast | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.combinedClickable | ||||||
|  | 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.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.widthIn | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.graphics.Brush | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.Global.updateRelate | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  | import com.acitelight.aether.view.pages.toHex | ||||||
|  | import com.acitelight.aether.viewModel.VideoScreenViewModel | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VideoCard( | ||||||
|  |     videos: List<Video>, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     videoScreenViewModel: VideoScreenViewModel | ||||||
|  | ) { | ||||||
|  |     val tabIndex by videoScreenViewModel.tabIndex; | ||||||
|  |     val video = videos.first() | ||||||
|  |     Card( | ||||||
|  |         colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(0.65f)), | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .wrapContentHeight() | ||||||
|  |             .combinedClickable( | ||||||
|  |                 onClick = { | ||||||
|  |                     updateRelate( | ||||||
|  |                         videoScreenViewModel.videoLibrary.classesMap[videoScreenViewModel.videoLibrary.classes[tabIndex]] | ||||||
|  |                             ?: mutableStateListOf(), video | ||||||
|  |                     ) | ||||||
|  |                     val vg = videos.joinToString(",") { "${it.klass}/${it.id}" }.toHex() | ||||||
|  |                     val route = "video_player_route/$vg" | ||||||
|  |                     navController.navigate(route) | ||||||
|  |                 }, | ||||||
|  |                 onLongClick = { | ||||||
|  |                     videoScreenViewModel.viewModelScope.launch { | ||||||
|  |                         for(i in videos) | ||||||
|  |                         { | ||||||
|  |                             videoScreenViewModel.download(i) | ||||||
|  |                         } | ||||||
|  |                         Toast.makeText( | ||||||
|  |                             videoScreenViewModel.context, | ||||||
|  |                             "Start downloading ${video.video.group}", | ||||||
|  |                             Toast.LENGTH_SHORT | ||||||
|  |                         ).show() | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |         shape = RoundedCornerShape(6.dp), | ||||||
|  |     ) { | ||||||
|  |         Column( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxWidth(), | ||||||
|  |         ) { | ||||||
|  |             Box(modifier = Modifier.fillMaxSize()) { | ||||||
|  |  | ||||||
|  |                 AsyncImage( | ||||||
|  |                     model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                         .data(video.getCover(videoScreenViewModel.apiClient)) | ||||||
|  |                         .memoryCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                         .diskCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                         .build(), | ||||||
|  |                     contentDescription = null, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .fillMaxSize() | ||||||
|  |                         .clip(RoundedCornerShape(8.dp)), | ||||||
|  |                     contentScale = ContentScale.Fit, | ||||||
|  |                     imageLoader = videoScreenViewModel.imageLoader!! | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .height(24.dp) | ||||||
|  |                         .background( | ||||||
|  |                             brush = Brush.verticalGradient( | ||||||
|  |                                 colors = listOf( | ||||||
|  |                                     Color.Transparent, | ||||||
|  |                                     Color.Black.copy(alpha = 0.6f) | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                         .align(Alignment.BottomCenter) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.BottomStart) | ||||||
|  |                         .padding(horizontal =  2.dp), | ||||||
|  |                     text = "${videos.size} Videos", | ||||||
|  |                     fontSize = 12.sp, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     lineHeight = 13.sp, | ||||||
|  |                     color = Color.White | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.BottomEnd) | ||||||
|  |                         .padding(horizontal =  2.dp), | ||||||
|  |                     text = formatTime(video.video.duration), | ||||||
|  |                     fontSize = 12.sp, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     lineHeight = 13.sp, | ||||||
|  |                     color = Color.White | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 if (videos.all{ it.isLocal }) | ||||||
|  |                     Card( | ||||||
|  |                         Modifier | ||||||
|  |                             .align(Alignment.TopStart) | ||||||
|  |                             .padding(5.dp) | ||||||
|  |                             .widthIn(max = 46.dp) | ||||||
|  |                     ) { | ||||||
|  |                         Box(Modifier.fillMaxWidth()) | ||||||
|  |                         { | ||||||
|  |                             Text( | ||||||
|  |                                 modifier = Modifier.align(Alignment.Center), | ||||||
|  |                                 text = "Local", | ||||||
|  |                                 fontSize = 14.sp, | ||||||
|  |                                 fontWeight = FontWeight.Bold | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |             } | ||||||
|  |             Text( | ||||||
|  |                 text = video.video.group ?: video.video.name, | ||||||
|  |                 fontSize = 12.sp, | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 maxLines = 2, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(4.dp) | ||||||
|  |                     .background(Color.Transparent) | ||||||
|  |                     .heightIn(min = 24.dp), | ||||||
|  |                 lineHeight = 14.sp | ||||||
|  |             ) | ||||||
|  |             Spacer(modifier = Modifier.weight(1f)) | ||||||
|  |             Box( | ||||||
|  |                 modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth() | ||||||
|  |             ) { | ||||||
|  |                 Text(modifier = Modifier.align(Alignment.CenterStart), text = "Class: ${video.klass}", fontSize = 10.sp, maxLines = 1) | ||||||
|  |                 Text(modifier = Modifier.align(Alignment.CenterEnd), text = "Id: ${ | ||||||
|  |                     videos.take(5).joinToString( | ||||||
|  |                         "," | ||||||
|  |                     ) { it.id } | ||||||
|  |                 }", fontSize = 10.sp, maxLines = 1) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,275 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | 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.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.filled.Delete | ||||||
|  | import androidx.compose.material.icons.filled.Pause | ||||||
|  | import androidx.compose.material.icons.filled.PlayArrow | ||||||
|  | import androidx.compose.material.icons.filled.Stop | ||||||
|  | import androidx.compose.material3.Button | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.LinearProgressIndicator | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.ProgressIndicatorDefaults | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.Global.updateRelate | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.model.VideoDownloadItemState | ||||||
|  | import com.acitelight.aether.view.pages.toHex | ||||||
|  | import com.acitelight.aether.viewModel.TransmissionScreenViewModel | ||||||
|  | import com.tonyodev.fetch2.Status | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import java.io.File | ||||||
|  | import kotlin.math.abs | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VideoDownloadCard( | ||||||
|  |     navigator: NavHostController, | ||||||
|  |     viewModel: TransmissionScreenViewModel, | ||||||
|  |     model: VideoDownloadItemState, | ||||||
|  |     onPause: () -> Unit, | ||||||
|  |     onResume: () -> Unit, | ||||||
|  |     onCancel: () -> Unit, | ||||||
|  |     onDelete: () -> Unit, | ||||||
|  |     onRetry: () -> Unit | ||||||
|  | ) { | ||||||
|  |     Card( | ||||||
|  |         shape = RoundedCornerShape(8.dp), | ||||||
|  |         elevation = CardDefaults.cardElevation(4.dp), | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .padding(8.dp) | ||||||
|  |             .background(Color.Transparent) | ||||||
|  |             .clickable(onClick = { | ||||||
|  |                 if (model.status == Status.COMPLETED) { | ||||||
|  |                     viewModel.viewModelScope.launch(Dispatchers.IO) | ||||||
|  |                     { | ||||||
|  |                         val downloaded = viewModel.fetchManager.getAllDownloadsAsync().filter { | ||||||
|  |                             it.status == Status.COMPLETED && it.extras.getString( | ||||||
|  |                                 "class", | ||||||
|  |                                 "" | ||||||
|  |                             ) != "comic" && it.extras.getString( | ||||||
|  |                                 "type", | ||||||
|  |                                 "" | ||||||
|  |                             ) == "main" | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         val jsonQuery = downloaded.map { | ||||||
|  |                             File( | ||||||
|  |                                 viewModel.context.getExternalFilesDir(null), | ||||||
|  |                                 "videos/${ | ||||||
|  |                                     it.extras.getString( | ||||||
|  |                                         "class", | ||||||
|  |                                         "" | ||||||
|  |                                     ) | ||||||
|  |                                 }/${it.extras.getString("id", "")}/summary.json" | ||||||
|  |                             ).readText() | ||||||
|  |                         } | ||||||
|  |                             .map { | ||||||
|  |                                 Json.decodeFromString<Video>(it) | ||||||
|  |                                     .toLocal(viewModel.context.getExternalFilesDir(null)!!.path) | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                         updateRelate( | ||||||
|  |                             jsonQuery, | ||||||
|  |                             jsonQuery.first { it.id == model.vid && it.klass == model.klass } | ||||||
|  |                         ) | ||||||
|  |  | ||||||
|  |                         val playList = mutableListOf<String>() | ||||||
|  |                         val fv = viewModel.videoLibrary.classesMap.map { it.value }.flatten() | ||||||
|  |                         val video = fv.firstOrNull { it.klass == model.klass && it.id == model.vid } | ||||||
|  |  | ||||||
|  |                         if (video != null) { | ||||||
|  |                             val group = fv.filter { it.klass == video.klass && it.video.group == video.video.group } | ||||||
|  |                             for (i in group.sortedWith(compareBy(naturalOrder()) { it.video.name })) { | ||||||
|  |                                 playList.add("${i.klass}/${i.id}") | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         val route = "video_player_route/${(playList.joinToString(",") + "|${model.vid}").toHex()}" | ||||||
|  |                         withContext(Dispatchers.Main) { | ||||||
|  |                             navigator.navigate(route) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |     ) { | ||||||
|  |         Column( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |                 .padding(12.dp) | ||||||
|  |         ) { | ||||||
|  |             Row( | ||||||
|  |                 verticalAlignment = Alignment.CenterVertically, | ||||||
|  |                 modifier = Modifier.fillMaxWidth() | ||||||
|  |             ) { | ||||||
|  |                 Column(modifier = Modifier.weight(1f)) { | ||||||
|  |                     Text(text = model.fileName, style = MaterialTheme.typography.titleMedium, maxLines = 2) | ||||||
|  |                     // Text(text = model.filePath, style = MaterialTheme.typography.titleSmall) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Box( | ||||||
|  |                 Modifier | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .padding(top = 5.dp) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Card( | ||||||
|  |                     shape = RoundedCornerShape(8.dp), | ||||||
|  |                     modifier = Modifier.align(Alignment.CenterStart) | ||||||
|  |                 ) { | ||||||
|  |                     val video = viewModel.modelToVideo(model) | ||||||
|  |  | ||||||
|  |                     if (video == null) | ||||||
|  |                         AsyncImage( | ||||||
|  |                             model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                                 .data( | ||||||
|  |                                     File( | ||||||
|  |                                         viewModel.context.getExternalFilesDir(null), | ||||||
|  |                                         "videos/${model.klass}/${model.vid}/cover.jpg" | ||||||
|  |                                     ) | ||||||
|  |                                 ) | ||||||
|  |                                 .memoryCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                                 .diskCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                                 .build(), | ||||||
|  |                             contentDescription = null, | ||||||
|  |                             modifier = Modifier.height(100.dp), | ||||||
|  |                             contentScale = ContentScale.Fit | ||||||
|  |                         ) | ||||||
|  |                     else { | ||||||
|  |                         AsyncImage( | ||||||
|  |                             model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                                 .data(video.getCover(viewModel.apiClient)) | ||||||
|  |                                 .memoryCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                                 .diskCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                                 .build(), | ||||||
|  |                             contentDescription = null, | ||||||
|  |                             modifier = Modifier.height(100.dp), | ||||||
|  |                             contentScale = ContentScale.Fit, | ||||||
|  |                             imageLoader = viewModel.imageLoader!! | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 Column(Modifier.align(Alignment.BottomEnd)) { | ||||||
|  |                     Text( | ||||||
|  |                         text = "${model.progress.coerceIn(0, 100)}%", | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(start = 8.dp) | ||||||
|  |                             .align(Alignment.End) | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(start = 8.dp) | ||||||
|  |                             .align(Alignment.End), | ||||||
|  |                         text = "%.2f MB/%.2f MB".format( | ||||||
|  |                             model.downloadedBytes / (1024.0 * 1024.0), | ||||||
|  |                             model.totalBytes / (1024.0 * 1024.0) | ||||||
|  |                         ), | ||||||
|  |                         fontSize = 10.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             // progress bar | ||||||
|  |             LinearProgressIndicator( | ||||||
|  |                 progress = { abs(model.progress).coerceIn(0, 100) / 100f }, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .padding(top = 8.dp, bottom = 8.dp), | ||||||
|  |                 color = ProgressIndicatorDefaults.linearColor, | ||||||
|  |                 trackColor = ProgressIndicatorDefaults.linearTrackColor, | ||||||
|  |                 strokeCap = ProgressIndicatorDefaults.LinearStrokeCap, | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             // action buttons | ||||||
|  |             Row( | ||||||
|  |                 horizontalArrangement = Arrangement.spacedBy(8.dp), | ||||||
|  |                 verticalAlignment = Alignment.CenterVertically, | ||||||
|  |                 modifier = Modifier.fillMaxWidth() | ||||||
|  |             ) { | ||||||
|  |                 when (model.status) { | ||||||
|  |                     Status.DOWNLOADING -> { | ||||||
|  |                         Button(onClick = onPause) { | ||||||
|  |                             Icon(imageVector = Icons.Default.Pause, contentDescription = "Pause") | ||||||
|  |                             Text(text = " Pause", modifier = Modifier.padding(start = 6.dp)) | ||||||
|  |                         } | ||||||
|  |                         Button(onClick = onCancel) { | ||||||
|  |                             Icon(imageVector = Icons.Default.Stop, contentDescription = "Cancel") | ||||||
|  |                             Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp)) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     Status.PAUSED, Status.QUEUED -> { | ||||||
|  |                         Button(onClick = onResume) { | ||||||
|  |                             Icon( | ||||||
|  |                                 imageVector = Icons.Default.PlayArrow, | ||||||
|  |                                 contentDescription = "Resume" | ||||||
|  |                             ) | ||||||
|  |                             Text(text = " Resume", modifier = Modifier.padding(start = 6.dp)) | ||||||
|  |                         } | ||||||
|  |                         Button(onClick = onCancel) { | ||||||
|  |                             Icon(imageVector = Icons.Default.Stop, contentDescription = "Cancel") | ||||||
|  |                             Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp)) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     Status.COMPLETED -> { | ||||||
|  |                         Button(onClick = onDelete) { | ||||||
|  |                             Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete") | ||||||
|  |                             Text(text = " Delete", modifier = Modifier.padding(start = 6.dp)) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     else -> { | ||||||
|  |                         // for FAILED, CANCELLED, REMOVED etc. | ||||||
|  |                         Button(onClick = onRetry) { | ||||||
|  |                             Icon( | ||||||
|  |                                 imageVector = Icons.Default.PlayArrow, | ||||||
|  |                                 contentDescription = "Retry" | ||||||
|  |                             ) | ||||||
|  |                             Text(text = " Retry", modifier = Modifier.padding(start = 6.dp)) | ||||||
|  |                         } | ||||||
|  |                         Button(onClick = onDelete) { | ||||||
|  |                             Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete") | ||||||
|  |                             Text(text = " Delete", modifier = Modifier.padding(start = 6.dp)) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,229 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.clickable | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.size | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.layout.widthIn | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.filled.Delete | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.IconButton | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.SliderDefaults | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | 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.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.VideoDownloadItemState | ||||||
|  | import com.acitelight.aether.viewModel.TransmissionScreenViewModel | ||||||
|  | import com.tonyodev.fetch2.Status | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import java.io.File | ||||||
|  | import kotlin.math.abs | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VideoDownloadCardMini( | ||||||
|  |     navigator: NavHostController, | ||||||
|  |     viewModel: TransmissionScreenViewModel, | ||||||
|  |     model: VideoDownloadItemState, | ||||||
|  |     onPause: () -> Unit, | ||||||
|  |     onResume: () -> Unit, | ||||||
|  |     onCancel: () -> Unit, | ||||||
|  |     onDelete: () -> Unit, | ||||||
|  |     onRetry: () -> Unit | ||||||
|  | ) { | ||||||
|  |     val video = viewModel.modelToVideo(model) | ||||||
|  |     val imageModel = | ||||||
|  |         if (video == null) | ||||||
|  |             ImageRequest.Builder(LocalContext.current) | ||||||
|  |                 .data( | ||||||
|  |                     File( | ||||||
|  |                         viewModel.context.getExternalFilesDir(null), | ||||||
|  |                         "videos/${model.klass}/${model.vid}/cover.jpg" | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |                 .memoryCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                 .diskCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                 .build() | ||||||
|  |         else | ||||||
|  |             ImageRequest.Builder(LocalContext.current) | ||||||
|  |                 .data(video.getCover(viewModel.apiClient)) | ||||||
|  |                 .memoryCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                 .diskCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                 .build() | ||||||
|  |  | ||||||
|  |     Card( | ||||||
|  |         colors = CardDefaults.cardColors(containerColor = Color.Transparent), | ||||||
|  |         shape = RoundedCornerShape(8.dp), | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .padding(horizontal = 4.dp) | ||||||
|  |             .background(Color.Transparent) | ||||||
|  |             .clickable(onClick = { | ||||||
|  |                 when (model.status) { | ||||||
|  |                     Status.COMPLETED -> viewModel.viewModelScope.launch(Dispatchers.IO) | ||||||
|  |                     { | ||||||
|  |                         viewModel.playStart(model, navigator) | ||||||
|  |                     } | ||||||
|  |                     Status.DOWNLOADING -> onPause() | ||||||
|  |                     Status.PAUSED -> onResume() | ||||||
|  |                     Status.ADDED, Status.FAILED, Status.CANCELLED -> onRetry() | ||||||
|  |                     else -> {} | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |             .height(100.dp) | ||||||
|  |     ) { | ||||||
|  |         Row( | ||||||
|  |             modifier = Modifier.fillMaxSize() | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Box(Modifier | ||||||
|  |                 .fillMaxHeight()) | ||||||
|  |             { | ||||||
|  |                 AsyncImage( | ||||||
|  |                     model = imageModel, | ||||||
|  |                     contentDescription = null, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .height(100.dp) | ||||||
|  |                         .clip(RoundedCornerShape(8.dp)) | ||||||
|  |                         .widthIn(max = 150.dp) | ||||||
|  |                         .background(Color.Black), | ||||||
|  |                     contentScale = ContentScale.Crop | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 IconButton( | ||||||
|  |                     onClick = onDelete, | ||||||
|  |                     Modifier | ||||||
|  |                         .padding(2.dp) | ||||||
|  |                         .size(24.dp) | ||||||
|  |                         .align(Alignment.TopStart) | ||||||
|  |                         .background(MaterialTheme.colorScheme.error, RoundedCornerShape(4.dp)) | ||||||
|  |                         .clip(RoundedCornerShape(4.dp)) | ||||||
|  |                 ) { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = Icons.Default.Delete, | ||||||
|  |                         contentDescription = "Delete", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier.size(20.dp) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Box( | ||||||
|  |                 Modifier | ||||||
|  |                     .fillMaxSize() | ||||||
|  |                     .padding(all = 4.dp) | ||||||
|  |                     .padding(end = 4.dp) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Text( | ||||||
|  |                     text = model.fileName, | ||||||
|  |                     lineHeight = 14.sp, | ||||||
|  |                     fontSize = 12.sp, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     maxLines = 2, | ||||||
|  |                     modifier = Modifier.align(Alignment.TopEnd) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Column(Modifier.align(Alignment.BottomEnd)) { | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier.align(Alignment.End), | ||||||
|  |                         text = when (model.status) { | ||||||
|  |                             Status.COMPLETED -> "Completed" | ||||||
|  |                             Status.PAUSED, Status.QUEUED -> "Paused" | ||||||
|  |                             Status.DOWNLOADING -> "Downloading" | ||||||
|  |                             else -> "Error" | ||||||
|  |                         }, | ||||||
|  |                         fontSize = 10.sp, | ||||||
|  |                         lineHeight = 11.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     Row(Modifier | ||||||
|  |                         .align(Alignment.End) | ||||||
|  |                         .padding(vertical = 2.dp)) { | ||||||
|  |                         Text( | ||||||
|  |                             modifier = Modifier, | ||||||
|  |                             text = "%.2f MB/%.2f MB".format( | ||||||
|  |                                 model.downloadedBytes / (1024.0 * 1024.0), | ||||||
|  |                                 model.totalBytes / (1024.0 * 1024.0) | ||||||
|  |                             ), | ||||||
|  |                             fontSize = 10.sp, | ||||||
|  |                             lineHeight = 11.sp, | ||||||
|  |                             fontWeight = FontWeight.Bold, | ||||||
|  |                             maxLines = 1, | ||||||
|  |                         ) | ||||||
|  |                         Spacer(Modifier.width(12.dp)) | ||||||
|  |                         Text( | ||||||
|  |                             text = "${model.progress.coerceIn(0, 100)}%", | ||||||
|  |                             modifier = Modifier, | ||||||
|  |                             fontSize = 10.sp, | ||||||
|  |                             lineHeight = 11.sp, | ||||||
|  |                             fontWeight = FontWeight.Bold, | ||||||
|  |                             maxLines = 1, | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                     BiliMiniSlider( | ||||||
|  |                         value = abs(model.progress).coerceIn(0, 100) / 100f, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .height(6.dp) | ||||||
|  |                             .align(Alignment.End) | ||||||
|  |                             .fillMaxWidth(), | ||||||
|  |                         onValueChange = { | ||||||
|  |  | ||||||
|  |                         }, | ||||||
|  |                         colors = when(model.status) | ||||||
|  |                         { | ||||||
|  |                             Status.DOWNLOADING, Status.QUEUED, Status.ADDED -> SliderDefaults.colors( | ||||||
|  |                                 thumbColor = Color(0xFFFFFFFF), | ||||||
|  |                                 activeTrackColor = MaterialTheme.colorScheme.primary, | ||||||
|  |                                 inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |                             ) | ||||||
|  |                             Status.PAUSED -> SliderDefaults.colors( | ||||||
|  |                                 thumbColor = Color(0xFFFFFFFF), | ||||||
|  |                                 activeTrackColor = Color(0xFFFFA500), | ||||||
|  |                                 inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |                             ) | ||||||
|  |                             Status.COMPLETED -> SliderDefaults.colors( | ||||||
|  |                                 thumbColor = Color(0xFFFFFFFF), | ||||||
|  |                                 activeTrackColor = MaterialTheme.colorScheme.primary, | ||||||
|  |                                 inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |                             ) | ||||||
|  |                             else -> SliderDefaults.colors( | ||||||
|  |                                 thumbColor = Color(0xFFFFFFFF), | ||||||
|  |                                 activeTrackColor = MaterialTheme.colorScheme.error, | ||||||
|  |                                 inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,627 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import android.app.Activity | ||||||
|  | import android.content.Context | ||||||
|  | import android.media.AudioManager | ||||||
|  | import android.view.View | ||||||
|  | import androidx.activity.compose.BackHandler | ||||||
|  | import androidx.annotation.OptIn | ||||||
|  | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | import androidx.compose.animation.fadeIn | ||||||
|  | import androidx.compose.animation.fadeOut | ||||||
|  | import androidx.compose.animation.slideInHorizontally | ||||||
|  | import androidx.compose.animation.slideInVertically | ||||||
|  | import androidx.compose.animation.slideOutHorizontally | ||||||
|  | import androidx.compose.animation.slideOutVertically | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.gestures.detectDragGestures | ||||||
|  | import androidx.compose.foundation.gestures.detectTapGestures | ||||||
|  | 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.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.foundation.lazy.rememberLazyListState | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.automirrored.filled.ArrowBack | ||||||
|  | import androidx.compose.material.icons.automirrored.filled.List | ||||||
|  | import androidx.compose.material.icons.automirrored.filled.VolumeUp | ||||||
|  | import androidx.compose.material.icons.filled.Brightness4 | ||||||
|  | import androidx.compose.material.icons.filled.FastForward | ||||||
|  | import androidx.compose.material.icons.filled.FullscreenExit | ||||||
|  | import androidx.compose.material.icons.filled.Lock | ||||||
|  | import androidx.compose.material.icons.filled.LockOpen | ||||||
|  | import androidx.compose.material.icons.filled.Pause | ||||||
|  | import androidx.compose.material.icons.filled.PlayArrow | ||||||
|  | import androidx.compose.material.icons.filled.SkipNext | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.IconButton | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.DisposableEffect | ||||||
|  | import androidx.compose.runtime.LaunchedEffect | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableFloatStateOf | ||||||
|  | import androidx.compose.runtime.remember | ||||||
|  | import androidx.compose.runtime.setValue | ||||||
|  | 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.input.pointer.pointerInput | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.platform.LocalView | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.compose.ui.viewinterop.AndroidView | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import androidx.media3.common.util.UnstableApi | ||||||
|  | import androidx.media3.exoplayer.ExoPlayer | ||||||
|  | import androidx.media3.ui.PlayerView | ||||||
|  | import com.acitelight.aether.setFullScreen | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  | import com.acitelight.aether.view.pages.moveBrit | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import kotlin.math.abs | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @OptIn(UnstableApi::class) | ||||||
|  | @Composable | ||||||
|  | fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     val context = LocalContext.current | ||||||
|  |     val activity = (context as? Activity)!! | ||||||
|  |     val exoPlayer: ExoPlayer = videoPlayerViewModel.player!! | ||||||
|  |  | ||||||
|  |     val name by videoPlayerViewModel.currentName | ||||||
|  |     val id by videoPlayerViewModel.currentId | ||||||
|  |     val listState = rememberLazyListState() | ||||||
|  |     val videos = videoPlayerViewModel.videos | ||||||
|  |  | ||||||
|  |     LaunchedEffect(id, videos) { | ||||||
|  |         val targetIndex = videos.indexOfFirst { it.id == id } | ||||||
|  |         if (targetIndex >= 0) { | ||||||
|  |             listState.scrollToItem(targetIndex) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } | ||||||
|  |     val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } | ||||||
|  |     var volFactor by remember { | ||||||
|  |         mutableFloatStateOf( | ||||||
|  |             audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume.toFloat() | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun setVolume(value: Int) { | ||||||
|  |         audioManager.setStreamVolume( | ||||||
|  |             AudioManager.STREAM_MUSIC, | ||||||
|  |             value.coerceIn(0, maxVolume), | ||||||
|  |             AudioManager.FLAG_PLAY_SOUND | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     BackHandler { | ||||||
|  |         videoPlayerViewModel.isLandscape = false | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val view = LocalView.current | ||||||
|  |     DisposableEffect(Unit) { | ||||||
|  |         setFullScreen(view, true) | ||||||
|  |         onDispose { | ||||||
|  |             setFullScreen(view, false) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Box(Modifier.fillMaxSize()) | ||||||
|  |     { | ||||||
|  |         Box( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .background(Color.Black) | ||||||
|  |                 .align(Alignment.Center) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Box( | ||||||
|  |                 Modifier | ||||||
|  |                     .fillMaxSize() | ||||||
|  |                     .pointerInput(videoPlayerViewModel) { | ||||||
|  |                         detectDragGestures( | ||||||
|  |                             onDragStart = { offset -> | ||||||
|  |                                 if (videoPlayerViewModel.locked) return@detectDragGestures | ||||||
|  |                                 if (offset.y > size.height * 0.9 || offset.y < size.height * 0.1) | ||||||
|  |                                     videoPlayerViewModel.draggingPurpose = -3 | ||||||
|  |                                 // Set gesture protection for the bottom of the screen | ||||||
|  |                                 // (Prevent conflicts with system gestures, such as dropdown status bar, bottom swipe up menu) | ||||||
|  |                                 else if (offset.x < size.width / 2) { | ||||||
|  |                                     videoPlayerViewModel.draggingPurpose = -1 | ||||||
|  |                                 } else { | ||||||
|  |                                     videoPlayerViewModel.draggingPurpose = -2 | ||||||
|  |                                 } | ||||||
|  |                             }, | ||||||
|  |                             onDragEnd = { | ||||||
|  |                                 if (videoPlayerViewModel.isPlaying && videoPlayerViewModel.draggingPurpose == 0) | ||||||
|  |                                     exoPlayer.play() | ||||||
|  |  | ||||||
|  |                                 videoPlayerViewModel.draggingPurpose = -1 | ||||||
|  |                             }, | ||||||
|  |                             onDrag = { change, dragAmount -> | ||||||
|  |                                 if (videoPlayerViewModel.locked) return@detectDragGestures | ||||||
|  |                                 if (abs(dragAmount.x) > abs(dragAmount.y) && | ||||||
|  |                                     (videoPlayerViewModel.draggingPurpose == -1 || videoPlayerViewModel.draggingPurpose == -2) | ||||||
|  |                                 ) { | ||||||
|  |                                     videoPlayerViewModel.draggingPurpose = 0 | ||||||
|  |                                     videoPlayerViewModel.planeVisibility = true | ||||||
|  |                                     exoPlayer.pause() | ||||||
|  |                                 } else if (videoPlayerViewModel.draggingPurpose == -1) videoPlayerViewModel.draggingPurpose = | ||||||
|  |                                     1 | ||||||
|  |                                 else if (videoPlayerViewModel.draggingPurpose == -2) videoPlayerViewModel.draggingPurpose = | ||||||
|  |                                     2 | ||||||
|  |  | ||||||
|  |                                 if (videoPlayerViewModel.draggingPurpose == 0) { | ||||||
|  |                                     exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong()) | ||||||
|  |                                     videoPlayerViewModel.playProcess = | ||||||
|  |                                         exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat() | ||||||
|  |                                 } else if (videoPlayerViewModel.draggingPurpose == 2) { | ||||||
|  |                                     val cu = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) | ||||||
|  |                                     volFactor = | ||||||
|  |                                         audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) | ||||||
|  |                                             .toFloat() / maxVolume.toFloat() | ||||||
|  |                                     if (dragAmount.y < 0) | ||||||
|  |                                         setVolume(cu + 1) | ||||||
|  |                                     else if (dragAmount.y > 0) | ||||||
|  |                                         setVolume(cu - 1) | ||||||
|  |                                 } else if (videoPlayerViewModel.draggingPurpose == 1) { | ||||||
|  |                                     moveBrit(dragAmount.y, activity, videoPlayerViewModel) | ||||||
|  |                                 } | ||||||
|  |  | ||||||
|  |                             } | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                     .pointerInput(videoPlayerViewModel) { | ||||||
|  |                         detectTapGestures( | ||||||
|  |                             onDoubleTap = { | ||||||
|  |                                 if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |  | ||||||
|  |                                 videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying | ||||||
|  |                                 if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() | ||||||
|  |                             }, | ||||||
|  |                             onTap = { | ||||||
|  |                                 if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |                                 if (videoPlayerViewModel.showPlaylist) { | ||||||
|  |                                     videoPlayerViewModel.showPlaylist = false | ||||||
|  |                                     return@detectTapGestures | ||||||
|  |                                 } | ||||||
|  |  | ||||||
|  |                                 videoPlayerViewModel.planeVisibility = | ||||||
|  |                                     !videoPlayerViewModel.planeVisibility | ||||||
|  |                             }, | ||||||
|  |                             onLongPress = { | ||||||
|  |                                 if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |  | ||||||
|  |                                 videoPlayerViewModel.isLongPressing = true | ||||||
|  |                                 exoPlayer.playbackParameters = exoPlayer.playbackParameters | ||||||
|  |                                     .withSpeed(3.0f) | ||||||
|  |                             }, | ||||||
|  |                             onPress = { offset -> | ||||||
|  |                                 val pressResult = tryAwaitRelease() | ||||||
|  |                                 if (pressResult && videoPlayerViewModel.isLongPressing) { | ||||||
|  |                                     videoPlayerViewModel.isLongPressing = false | ||||||
|  |                                     exoPlayer.playbackParameters = exoPlayer.playbackParameters | ||||||
|  |                                         .withSpeed(1.0f) | ||||||
|  |                                 } | ||||||
|  |                             }, | ||||||
|  |                         ) | ||||||
|  |                     }) { | ||||||
|  |                 AndroidView( | ||||||
|  |                     factory = { | ||||||
|  |                         PlayerView( | ||||||
|  |                             it | ||||||
|  |                         ).apply { | ||||||
|  |                             player = exoPlayer | ||||||
|  |                             useController = false | ||||||
|  |                             subtitleView?.let { sv -> | ||||||
|  |                                 sv.visibility = View.GONE | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.Center) | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.draggingPurpose == 0, | ||||||
|  |                 enter = fadeIn( | ||||||
|  |                     initialAlpha = 0f, | ||||||
|  |                 ), | ||||||
|  |                 exit = fadeOut( | ||||||
|  |                     targetAlpha = 0f | ||||||
|  |                 ), | ||||||
|  |                 modifier = Modifier.align(Alignment.Center) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Text( | ||||||
|  |                     text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${ | ||||||
|  |                         formatTime(exoPlayer.duration) | ||||||
|  |                     }", | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     modifier = Modifier.padding(bottom = 12.dp), | ||||||
|  |                     fontSize = 18.sp | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.draggingPurpose == 2, | ||||||
|  |                 enter = fadeIn( | ||||||
|  |                     initialAlpha = 0f, | ||||||
|  |                 ), | ||||||
|  |                 exit = fadeOut( | ||||||
|  |                     targetAlpha = 0f | ||||||
|  |                 ), | ||||||
|  |                 modifier = Modifier.align(Alignment.Center) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Row( | ||||||
|  |                     Modifier | ||||||
|  |                         .background(Color(0x88000000), RoundedCornerShape(18)) | ||||||
|  |                         .width(200.dp) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = Icons.AutoMirrored.Filled.VolumeUp, | ||||||
|  |                         contentDescription = "Vol", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .size(48.dp) | ||||||
|  |                             .padding(8.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) | ||||||
|  |                     BiliMiniSlider( | ||||||
|  |                         value = volFactor, | ||||||
|  |                         onValueChange = {}, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .height(4.dp) | ||||||
|  |                             .padding(horizontal = 8.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.draggingPurpose == 1, | ||||||
|  |                 enter = fadeIn( | ||||||
|  |                     initialAlpha = 0f, | ||||||
|  |                 ), | ||||||
|  |                 exit = fadeOut( | ||||||
|  |                     targetAlpha = 0f | ||||||
|  |                 ), | ||||||
|  |                 modifier = Modifier.align(Alignment.Center) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Row( | ||||||
|  |                     Modifier | ||||||
|  |                         .background(Color(0x88000000), RoundedCornerShape(18)) | ||||||
|  |                         .width(200.dp) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = Icons.Default.Brightness4, | ||||||
|  |                         contentDescription = "Brightness", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .size(48.dp) | ||||||
|  |                             .padding(8.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) | ||||||
|  |                     BiliMiniSlider( | ||||||
|  |                         value = videoPlayerViewModel.brit, | ||||||
|  |                         onValueChange = {}, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .height(4.dp) | ||||||
|  |                             .padding(horizontal = 8.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.isLongPressing, | ||||||
|  |                 enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |                 exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.TopCenter) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Box( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.TopCenter) | ||||||
|  |                         .padding(top = 24.dp) | ||||||
|  |                         .background(Color(0x44000000), RoundedCornerShape(18)) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Row { | ||||||
|  |                         Icon( | ||||||
|  |                             imageVector = Icons.Filled.FastForward, | ||||||
|  |                             contentDescription = "Fast Forward", | ||||||
|  |                             tint = Color.White, | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .size(36.dp) | ||||||
|  |                                 .padding(4.dp) | ||||||
|  |                                 .align(Alignment.CenterVertically) | ||||||
|  |                         ) | ||||||
|  |  | ||||||
|  |                         Text( | ||||||
|  |                             text = "3X Speed...", | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .padding(4.dp) | ||||||
|  |                                 .align(Alignment.CenterVertically), | ||||||
|  |                             fontSize = 16.sp, | ||||||
|  |                             fontWeight = FontWeight.Bold, | ||||||
|  |                             color = Color(0xFFFFFFFF) | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.planeVisibility && (!videoPlayerViewModel.locked), | ||||||
|  |                 enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |                 exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.TopCenter) | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Row( | ||||||
|  |                     Modifier | ||||||
|  |                         .align(Alignment.TopStart) | ||||||
|  |                         .background( | ||||||
|  |                             brush = Brush.verticalGradient( | ||||||
|  |                                 colors = listOf( | ||||||
|  |                                     Color.Black.copy(alpha = 0.4f), | ||||||
|  |                                     Color.Transparent, | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     IconButton( | ||||||
|  |                         onClick = { | ||||||
|  |                             videoPlayerViewModel.isLandscape = false | ||||||
|  |                         }, | ||||||
|  |                         Modifier | ||||||
|  |                             .padding(top = 12.dp) | ||||||
|  |                             .padding(start = 46.dp) | ||||||
|  |                             .size(36.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) { | ||||||
|  |                         Icon( | ||||||
|  |                             Icons.AutoMirrored.Filled.ArrowBack, | ||||||
|  |                             contentDescription = "Back", | ||||||
|  |                             tint = Color.White, | ||||||
|  |                             modifier = Modifier.size(32.dp) | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     Text( | ||||||
|  |                         text = name, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(top = 12.dp) | ||||||
|  |                             .align(Alignment.CenterVertically), | ||||||
|  |                         fontSize = 18.sp | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.planeVisibility && (!videoPlayerViewModel.locked), | ||||||
|  |                 enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }), | ||||||
|  |                 exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }), | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.BottomCenter) | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Column( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.BottomCenter) | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .background( | ||||||
|  |                             brush = Brush.verticalGradient( | ||||||
|  |                                 colors = listOf( | ||||||
|  |                                     Color.Transparent, | ||||||
|  |                                     Color.Black.copy(alpha = 0.4f) | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                         .padding(horizontal = 36.dp) | ||||||
|  |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${ | ||||||
|  |                             formatTime(exoPlayer.duration) | ||||||
|  |                         }", | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         modifier = Modifier.padding(bottom = 12.dp), | ||||||
|  |                         fontSize = 12.sp | ||||||
|  |                     ) | ||||||
|  |                     BiliStyleSlider( | ||||||
|  |                         value = videoPlayerViewModel.playProcess, | ||||||
|  |                         onValueChange = { value -> | ||||||
|  |                             exoPlayer.seekTo((exoPlayer.duration * value).toLong()) | ||||||
|  |                         }, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .height(16.dp) | ||||||
|  |                             .fillMaxWidth() | ||||||
|  |                             .padding(bottom = 8.dp) | ||||||
|  |                     ) | ||||||
|  |                     Row( | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .fillMaxWidth() | ||||||
|  |                             .padding(bottom = 8.dp) | ||||||
|  |                             .align(Alignment.Start), | ||||||
|  |                         horizontalArrangement = Arrangement.SpaceBetween, | ||||||
|  |                     ) { | ||||||
|  |                         IconButton( | ||||||
|  |                             onClick = { | ||||||
|  |                                 videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying | ||||||
|  |                                 if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() | ||||||
|  |                             }, | ||||||
|  |                             Modifier.size(42.dp) | ||||||
|  |                         ) { | ||||||
|  |                             Icon( | ||||||
|  |                                 imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, | ||||||
|  |                                 contentDescription = "Play/Pause", | ||||||
|  |                                 tint = Color.White, | ||||||
|  |                                 modifier = Modifier.size(42.dp) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         IconButton( | ||||||
|  |                             onClick = { | ||||||
|  |                                 videoPlayerViewModel.viewModelScope.launch { | ||||||
|  |                                     videoPlayerViewModel.startPlay( | ||||||
|  |                                         videoPlayerViewModel.videos.getOrNull(videoPlayerViewModel.videos.indexOf( | ||||||
|  |                                             videoPlayerViewModel.videos.first { | ||||||
|  |                                                 it.id == videoPlayerViewModel.currentId.value | ||||||
|  |                                             }) + 1) ?: videoPlayerViewModel.videos.first() | ||||||
|  |                                     ) | ||||||
|  |                                 } | ||||||
|  |                             }, | ||||||
|  |                             Modifier.size(42.dp) | ||||||
|  |                         ) { | ||||||
|  |                             Icon( | ||||||
|  |                                 imageVector = Icons.Default.SkipNext, | ||||||
|  |                                 contentDescription = "Next", | ||||||
|  |                                 tint = Color.White, | ||||||
|  |                                 modifier = Modifier.size(42.dp) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         Spacer(Modifier.weight(1f)) | ||||||
|  |  | ||||||
|  |                         IconButton( | ||||||
|  |                             onClick = { | ||||||
|  |                                 videoPlayerViewModel.isLandscape = false | ||||||
|  |                             }, | ||||||
|  |                             Modifier | ||||||
|  |                                 .size(36.dp) | ||||||
|  |                                 .align(Alignment.CenterVertically) | ||||||
|  |                         ) { | ||||||
|  |                             Icon( | ||||||
|  |                                 Icons.Default.FullscreenExit, | ||||||
|  |                                 contentDescription = "Exit FullScreen", | ||||||
|  |                                 tint = Color.White, | ||||||
|  |                                 modifier = Modifier.size(32.dp) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         IconButton( | ||||||
|  |                             onClick = { | ||||||
|  |                                 videoPlayerViewModel.showPlaylist = true | ||||||
|  |                             }, | ||||||
|  |                             Modifier | ||||||
|  |                                 .size(36.dp) | ||||||
|  |                                 .align(Alignment.CenterVertically) | ||||||
|  |                         ) { | ||||||
|  |                             Icon( | ||||||
|  |                                 Icons.AutoMirrored.Filled.List, | ||||||
|  |                                 contentDescription = "Playlist", | ||||||
|  |                                 tint = Color.White, | ||||||
|  |                                 modifier = Modifier.size(32.dp) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.locked || videoPlayerViewModel.planeVisibility, | ||||||
|  |                 enter = fadeIn( | ||||||
|  |                     initialAlpha = 0f, | ||||||
|  |                 ), | ||||||
|  |                 exit = fadeOut( | ||||||
|  |                     targetAlpha = 0f | ||||||
|  |                 ), | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterEnd) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Card( | ||||||
|  |                     modifier = Modifier.padding(4.dp), | ||||||
|  |                     colors = CardDefaults.cardColors( | ||||||
|  |                         containerColor = colorScheme.primary.copy( | ||||||
|  |                             if (videoPlayerViewModel.locked) 0.2f else 1f | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                     onClick = { | ||||||
|  |                         videoPlayerViewModel.locked = !videoPlayerViewModel.locked | ||||||
|  |                     }) { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = if (videoPlayerViewModel.locked) Icons.Default.LockOpen else Icons.Default.Lock, | ||||||
|  |                         contentDescription = "Lock", | ||||||
|  |                         tint = Color.White.copy(if (videoPlayerViewModel.locked) 0.2f else 1f), | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .size(36.dp) | ||||||
|  |                             .padding(6.dp) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.showPlaylist, | ||||||
|  |                 enter = slideInHorizontally(initialOffsetX = { full -> full }), | ||||||
|  |                 exit = slideOutHorizontally(targetOffsetX = { full -> full }), | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterEnd) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Card( | ||||||
|  |                     Modifier | ||||||
|  |                         .fillMaxHeight() | ||||||
|  |                         .width(320.dp) | ||||||
|  |                         .align(Alignment.CenterEnd), | ||||||
|  |                     shape = RoundedCornerShape(8.dp), | ||||||
|  |                     colors = CardDefaults.cardColors(containerColor = colorScheme.surface.copy(0.75f)) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     LazyColumn(state = listState, contentPadding = PaddingValues(vertical = 4.dp)) { | ||||||
|  |                         items(videoPlayerViewModel.videos) { item -> | ||||||
|  |                             MiniPlaylistCard(Modifier.padding(4.dp), video = item, imageLoader = videoPlayerViewModel.imageLoader!!, | ||||||
|  |                                 selected = id == item.id, apiClient = videoPlayerViewModel.apiClient) | ||||||
|  |                             { | ||||||
|  |                                 if (name == item.video.name) | ||||||
|  |                                     return@MiniPlaylistCard | ||||||
|  |  | ||||||
|  |                                 videoPlayerViewModel.viewModelScope.launch { | ||||||
|  |                                     videoPlayerViewModel.startPlay(item) | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             SubtitleOverlay( | ||||||
|  |                 cues = videoPlayerViewModel.cues, | ||||||
|  |                 modifier = Modifier.matchParentSize() | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,277 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | import androidx.compose.animation.fadeIn | ||||||
|  | import androidx.compose.animation.fadeOut | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.size | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.rememberLazyListState | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.filled.Lock | ||||||
|  | import androidx.compose.material.icons.filled.LockOpen | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.DividerDefaults | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Tab | ||||||
|  | import androidx.compose.material3.TabRow | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableFloatStateOf | ||||||
|  | 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.alpha | ||||||
|  | import androidx.compose.ui.geometry.Offset | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection | ||||||
|  | import androidx.compose.ui.input.nestedscroll.NestedScrollSource | ||||||
|  | import androidx.compose.ui.input.nestedscroll.nestedScroll | ||||||
|  | import androidx.compose.ui.layout.onGloballyPositioned | ||||||
|  | import androidx.compose.ui.platform.LocalConfiguration | ||||||
|  | import androidx.compose.ui.platform.LocalDensity | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import com.acitelight.aether.Global | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  | import com.acitelight.aether.view.pages.toHex | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VideoPlayerPortal( | ||||||
|  |     videoPlayerViewModel: VideoPlayerViewModel, | ||||||
|  |     navController: NavHostController | ||||||
|  | ) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     val configuration = LocalConfiguration.current | ||||||
|  |     val screenHeight = configuration.screenHeightDp.dp | ||||||
|  |  | ||||||
|  |     val minHeight = 42.dp | ||||||
|  |     var coverAlpha by remember { mutableFloatStateOf(0.0f) } | ||||||
|  |     var maxHeight = remember { screenHeight * 0.65f } | ||||||
|  |     var posed = remember { false } | ||||||
|  |     val dens = LocalDensity.current | ||||||
|  |     val listState = rememberLazyListState() | ||||||
|  |  | ||||||
|  |     var playerHeight by remember { mutableStateOf(screenHeight * 0.65f) } | ||||||
|  |  | ||||||
|  |     val nestedScrollConnection = remember { | ||||||
|  |         object : NestedScrollConnection { | ||||||
|  |             override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { | ||||||
|  |                 val deltaY = available.y // px | ||||||
|  |                 val deltaDp = with(dens) { deltaY.toDp() } | ||||||
|  |  | ||||||
|  |                 val r = if (deltaY < 0 && playerHeight > minHeight) { | ||||||
|  |                     val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight) | ||||||
|  |                     val consumedDp = newHeight - playerHeight | ||||||
|  |                     playerHeight = newHeight | ||||||
|  |                     val consumedPx = with(dens) { consumedDp.toPx() } | ||||||
|  |                     Offset(0f, consumedPx) | ||||||
|  |                 } else if (deltaY > 0 && playerHeight < maxHeight && listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0) { | ||||||
|  |                     val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight) | ||||||
|  |                     val consumedDp = newHeight - playerHeight | ||||||
|  |                     playerHeight = newHeight | ||||||
|  |                     val consumedPx = with(dens) { consumedDp.toPx() } | ||||||
|  |                     Offset(0f, consumedPx) | ||||||
|  |                 } else { | ||||||
|  |                     Offset.Zero | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 val dh = playerHeight - minHeight | ||||||
|  |                 coverAlpha = (if (dh > 10.dp) | ||||||
|  |                     0f | ||||||
|  |                 else | ||||||
|  |                     (10.dp.value - dh.value) / 10.0f) | ||||||
|  |  | ||||||
|  |                 return r | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val klass by videoPlayerViewModel.currentKlass | ||||||
|  |     val id by videoPlayerViewModel.currentId | ||||||
|  |     val name by videoPlayerViewModel.currentName | ||||||
|  |     val duration by videoPlayerViewModel.currentDuration | ||||||
|  |  | ||||||
|  |     Column( | ||||||
|  |         Modifier | ||||||
|  |             .nestedScroll(nestedScrollConnection) | ||||||
|  |             .fillMaxHeight() | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         Box { | ||||||
|  |             PortalCorePlayer( | ||||||
|  |                 Modifier | ||||||
|  |                     .heightIn(max = playerHeight) | ||||||
|  |                     .onGloballyPositioned { layoutCoordinates -> | ||||||
|  |                         if (!posed && videoPlayerViewModel.renderedFirst) { | ||||||
|  |                             maxHeight = with(dens) { layoutCoordinates.size.height.toDp() } | ||||||
|  |                             playerHeight = maxHeight | ||||||
|  |                             posed = true | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                 videoPlayerViewModel = videoPlayerViewModel, coverAlpha | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             androidx.compose.animation.AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.locked || videoPlayerViewModel.planeVisibility, | ||||||
|  |                 enter = fadeIn( | ||||||
|  |                     initialAlpha = 0f, | ||||||
|  |                 ), | ||||||
|  |                 exit = fadeOut( | ||||||
|  |                     targetAlpha = 0f | ||||||
|  |                 ), | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterEnd) | ||||||
|  |             ) { | ||||||
|  |                 Card( | ||||||
|  |                     modifier = Modifier.padding(4.dp), | ||||||
|  |                     colors = CardDefaults.cardColors( | ||||||
|  |                         containerColor = colorScheme.primary.copy( | ||||||
|  |                             if (videoPlayerViewModel.locked) 0.2f else 1f | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                     onClick = { | ||||||
|  |                         videoPlayerViewModel.locked = !videoPlayerViewModel.locked | ||||||
|  |                     }) { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = if (videoPlayerViewModel.locked) Icons.Default.LockOpen else Icons.Default.Lock, | ||||||
|  |                         contentDescription = "Lock", | ||||||
|  |                         tint = Color.White.copy(if (videoPlayerViewModel.locked) 0.2f else 1f), | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .size(36.dp) | ||||||
|  |                             .padding(6.dp) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Row() | ||||||
|  |         { | ||||||
|  |             TabRow( | ||||||
|  |                 selectedTabIndex = videoPlayerViewModel.tabIndex, | ||||||
|  |                 modifier = Modifier.height(38.dp) | ||||||
|  |             ) { | ||||||
|  |                 Tab( | ||||||
|  |                     selected = videoPlayerViewModel.tabIndex == 0, | ||||||
|  |                     onClick = { videoPlayerViewModel.tabIndex = 0 }, | ||||||
|  |                     text = { Text(text = "Introduction", maxLines = 1) }, | ||||||
|  |                     modifier = Modifier.height(38.dp) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Tab( | ||||||
|  |                     selected = videoPlayerViewModel.tabIndex == 1, | ||||||
|  |                     onClick = { videoPlayerViewModel.tabIndex = 1 }, | ||||||
|  |                     text = { Text(text = "Comment", maxLines = 1) }, | ||||||
|  |                     modifier = Modifier.height(38.dp) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         LazyColumn(state = listState, modifier = Modifier.fillMaxWidth()) { | ||||||
|  |             item { | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.Start) | ||||||
|  |                         .padding(horizontal = 12.dp) | ||||||
|  |                         .padding(top = 12.dp), | ||||||
|  |                     text = name, | ||||||
|  |                     fontSize = 16.sp, | ||||||
|  |                     maxLines = 2, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Row( | ||||||
|  |                     Modifier | ||||||
|  |                         .align(Alignment.Start) | ||||||
|  |                         .padding(horizontal = 4.dp) | ||||||
|  |                         .alpha(0.5f) | ||||||
|  |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier.padding(horizontal = 8.dp), | ||||||
|  |                         text = "$klass.$id", | ||||||
|  |                         fontSize = 14.sp, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier.padding(horizontal = 8.dp), | ||||||
|  |                         text = formatTime(duration), | ||||||
|  |                         fontSize = 14.sp, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color) | ||||||
|  |  | ||||||
|  |                 if (videoPlayerViewModel.videos.size > 1) { | ||||||
|  |                     PlaylistPanel( | ||||||
|  |                         Modifier, | ||||||
|  |                         videoPlayerViewModel = videoPlayerViewModel | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     HorizontalDivider( | ||||||
|  |                         Modifier.padding(vertical = 8.dp), | ||||||
|  |                         1.dp, | ||||||
|  |                         DividerDefaults.color | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 HorizontalGallery(videoPlayerViewModel) | ||||||
|  |                 HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color) | ||||||
|  |  | ||||||
|  |                 for (i in Global.sameClassVideos ?: listOf()) { | ||||||
|  |                     if (i.id == id) continue | ||||||
|  |  | ||||||
|  |                     MiniVideoCard( | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(horizontal = 12.dp), | ||||||
|  |                         i, | ||||||
|  |                         apiClient = videoPlayerViewModel.apiClient, | ||||||
|  |                         imageLoader = videoPlayerViewModel.imageLoader!! | ||||||
|  |                     ) { | ||||||
|  |                         videoPlayerViewModel.isPlaying = false | ||||||
|  |                         videoPlayerViewModel.player?.pause() | ||||||
|  |  | ||||||
|  |                         val playList = mutableListOf<String>() | ||||||
|  |                         val fv = | ||||||
|  |                             videoPlayerViewModel.videoLibrary.classesMap.map { it.value }.flatten() | ||||||
|  |  | ||||||
|  |                         val group = | ||||||
|  |                             fv.filter { it.klass == i.klass && it.video.group == i.video.group } | ||||||
|  |                         for (i in group) { | ||||||
|  |                             playList.add("${i.klass}/${i.id}") | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         val route = "video_player_route/${(playList.joinToString(",") + "|${i.id}").toHex()}" | ||||||
|  |                         navController.navigate(route) | ||||||
|  |                     } | ||||||
|  |                     HorizontalDivider( | ||||||
|  |                         Modifier | ||||||
|  |                             .padding(vertical = 8.dp) | ||||||
|  |                             .alpha(0.25f), | ||||||
|  |                         1.dp, | ||||||
|  |                         DividerDefaults.color | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,438 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | 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.FlowRow | ||||||
|  | import androidx.compose.foundation.layout.PaddingValues | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.layout.widthIn | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.LazyRow | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.foundation.lazy.rememberLazyListState | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.LazyHorizontalStaggeredGrid | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState | ||||||
|  | import androidx.compose.foundation.pager.HorizontalPager | ||||||
|  | import androidx.compose.foundation.pager.rememberPagerState | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Button | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.DisposableEffect | ||||||
|  | import androidx.compose.runtime.LaunchedEffect | ||||||
|  | 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.geometry.Offset | ||||||
|  | import androidx.compose.ui.graphics.Brush | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection | ||||||
|  | import androidx.compose.ui.input.nestedscroll.NestedScrollSource | ||||||
|  | import androidx.compose.ui.input.nestedscroll.nestedScroll | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalConfiguration | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.platform.LocalDensity | ||||||
|  | import androidx.compose.ui.platform.LocalView | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.min | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  |  | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.BookMark | ||||||
|  | import com.acitelight.aether.model.Comic | ||||||
|  | import com.acitelight.aether.setFullScreen | ||||||
|  | import com.acitelight.aether.view.components.BiliMiniSlider | ||||||
|  | import com.acitelight.aether.viewModel.ComicGridViewModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun ComicGridView( | ||||||
|  |     comicId: String, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     comicGridViewModel: ComicGridViewModel = hiltViewModel<ComicGridViewModel>() | ||||||
|  | ) { | ||||||
|  |     comicGridViewModel.resolve(comicId.hexToString()) | ||||||
|  |     comicGridViewModel.updateProcess(comicId.hexToString()) {} | ||||||
|  |     val configuration = LocalConfiguration.current | ||||||
|  |     val screenHeight = configuration.screenHeightDp.dp | ||||||
|  |     val screenWidth = configuration.screenWidthDp.dp | ||||||
|  |     val record by comicGridViewModel.record | ||||||
|  |     val comic by comicGridViewModel.comic | ||||||
|  |  | ||||||
|  |     val view = LocalView.current | ||||||
|  |     DisposableEffect(Unit) { | ||||||
|  |         setFullScreen(view, true) | ||||||
|  |         onDispose { | ||||||
|  |             val nextRoute = navController.currentBackStackEntry?.destination?.route | ||||||
|  |             if (nextRoute?.startsWith("comic_page_route") != true) { | ||||||
|  |                 setFullScreen(view, false) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     LaunchedEffect(comicGridViewModel) { | ||||||
|  |         comicGridViewModel.coverHeight = screenHeight * 0.3f | ||||||
|  |         if(comicGridViewModel.maxHeight == 0.dp) | ||||||
|  |             comicGridViewModel.maxHeight = screenHeight * 0.8f | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val dens = LocalDensity.current | ||||||
|  |     val listState = rememberLazyListState() | ||||||
|  |  | ||||||
|  |     val nestedScrollConnection = remember { | ||||||
|  |         object : NestedScrollConnection { | ||||||
|  |             override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { | ||||||
|  |                 val deltaY = available.y // px | ||||||
|  |                 val deltaDp = with(dens) { deltaY.toDp() } | ||||||
|  |  | ||||||
|  |                 val r = if (deltaY < 0 && comicGridViewModel.coverHeight > 0.dp) { | ||||||
|  |                     val newHeight = (comicGridViewModel.coverHeight + deltaDp).coerceIn(0.dp, comicGridViewModel.maxHeight) | ||||||
|  |                     val consumedDp = newHeight - comicGridViewModel.coverHeight | ||||||
|  |                     comicGridViewModel.coverHeight = newHeight | ||||||
|  |                     val consumedPx = with(dens) { consumedDp.toPx() } | ||||||
|  |                     Offset(0f, consumedPx) | ||||||
|  |                 } else if ( | ||||||
|  |                     deltaY > 0 | ||||||
|  |                     && comicGridViewModel.coverHeight < comicGridViewModel.maxHeight | ||||||
|  |                     && listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 | ||||||
|  |                     ) { | ||||||
|  |                     val newHeight = (comicGridViewModel.coverHeight + deltaDp).coerceIn(0.dp, comicGridViewModel.maxHeight) | ||||||
|  |                     val consumedDp = newHeight - comicGridViewModel.coverHeight | ||||||
|  |                     comicGridViewModel.coverHeight = newHeight | ||||||
|  |                     val consumedPx = with(dens) { consumedDp.toPx() } | ||||||
|  |                     Offset(0f, consumedPx) | ||||||
|  |                 } else { | ||||||
|  |                     Offset.Zero | ||||||
|  |                 } | ||||||
|  |                 return r | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (comic != null) { | ||||||
|  |         val comic = comic!! | ||||||
|  |         val pagerState = rememberPagerState( | ||||||
|  |             initialPage = 0, | ||||||
|  |             pageCount = { comic.comic.bookmarks.size }) | ||||||
|  |  | ||||||
|  |         Column(Modifier | ||||||
|  |             .nestedScroll(nestedScrollConnection).fillMaxSize()) { | ||||||
|  |             Box(Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |                 .height(comicGridViewModel.coverHeight)) | ||||||
|  |             { | ||||||
|  |                 HorizontalPager( | ||||||
|  |                     state = pagerState, | ||||||
|  |                     modifier = Modifier.fillMaxSize() | ||||||
|  |                 ) | ||||||
|  |                 { page -> | ||||||
|  |                     AsyncImage( | ||||||
|  |                         model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                             .data(comic.getPage(comic.comic.bookmarks[page].page, comicGridViewModel.apiClient)) | ||||||
|  |                             .memoryCacheKey("${comic.id}/${comic.comic.bookmarks[page].page}") | ||||||
|  |                             .diskCacheKey("${comic.id}/${comic.comic.bookmarks[page].page}") | ||||||
|  |                             .build(), | ||||||
|  |                         contentDescription = null, | ||||||
|  |                         imageLoader = comicGridViewModel.imageLoader!!, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .fillMaxSize(), | ||||||
|  |                         contentScale = ContentScale.FillWidth, | ||||||
|  |                         onSuccess = { success -> | ||||||
|  |                             val drawable = success.result.image | ||||||
|  |                             val width = drawable.width | ||||||
|  |                             val height = drawable.height | ||||||
|  |                             val aspectRatio = width.toFloat() / height.toFloat() | ||||||
|  |                             comicGridViewModel.maxHeight = min(screenWidth / aspectRatio, screenHeight * 0.8f) | ||||||
|  |  | ||||||
|  |                             if(comicGridViewModel.coverHeight > comicGridViewModel.maxHeight) | ||||||
|  |                                 comicGridViewModel.coverHeight = comicGridViewModel.maxHeight | ||||||
|  |                         }, | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 Box(modifier = Modifier | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .align(Alignment.BottomCenter) | ||||||
|  |                         .height(50.dp) | ||||||
|  |                         .background( | ||||||
|  |                             brush = Brush.verticalGradient( | ||||||
|  |                                 colors = listOf( | ||||||
|  |                                     Color.Transparent, | ||||||
|  |                                     Color.Black.copy(alpha = 0.5f), | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 BiliMiniSlider( | ||||||
|  |                     value = (pagerState.currentPage + 1) / pagerState.pageCount.toFloat(), | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .height(6.dp) | ||||||
|  |                         .width(100.dp) | ||||||
|  |                         .align(Alignment.BottomCenter) | ||||||
|  |                         .fillMaxWidth(), | ||||||
|  |                     onValueChange = { | ||||||
|  |  | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             LazyColumn( | ||||||
|  |                 state = listState, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxSize() | ||||||
|  |                     .padding(top = 6.dp) | ||||||
|  |                     .clip(RoundedCornerShape(6.dp)) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 item() | ||||||
|  |                 { | ||||||
|  |                     Text( | ||||||
|  |                         text = comic.comic.comic_name, | ||||||
|  |                         fontSize = 18.sp, | ||||||
|  |                         lineHeight = 22.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                         modifier = Modifier.padding(horizontal = 16.dp).padding(top = 16.dp).padding(bottom = 4.dp) | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     FlowRow( | ||||||
|  |                         modifier =  Modifier.padding(horizontal =  16.dp).padding(bottom = 4.dp) | ||||||
|  |                     ) | ||||||
|  |                     { | ||||||
|  |                         comic.comic.tags.take(15).forEach() | ||||||
|  |                         { | ||||||
|  |                             ic -> | ||||||
|  |                             Card( | ||||||
|  |                                 Modifier.padding(1.dp), | ||||||
|  |                                 shape = RoundedCornerShape(8.dp) | ||||||
|  |                             ) { | ||||||
|  |                                 Text( | ||||||
|  |                                     text = ic, | ||||||
|  |                                     fontSize = 10.sp, | ||||||
|  |                                     lineHeight = 12.sp, | ||||||
|  |                                     fontWeight = FontWeight.Bold, | ||||||
|  |                                     maxLines = 2, | ||||||
|  |                                     modifier = Modifier | ||||||
|  |                                         .padding(4.dp) | ||||||
|  |                                 ) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     Box(Modifier.fillMaxWidth()) | ||||||
|  |                     { | ||||||
|  |                         Text( | ||||||
|  |                             text = "Author: ${comic.comic.author} \n${comic.comic.list.size} Pages", | ||||||
|  |                             fontSize = 11.sp, | ||||||
|  |                             lineHeight = 15.sp, | ||||||
|  |                             maxLines = 3, | ||||||
|  |                             modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 4.dp).align(Alignment.CenterStart) | ||||||
|  |                         ) | ||||||
|  |  | ||||||
|  |                         Button(onClick = { | ||||||
|  |                             comicGridViewModel.updateProcess(comicId.hexToString()) | ||||||
|  |                             { | ||||||
|  |                                 if (record != null) { | ||||||
|  |                                     val route = "comic_page_route/${comic.id.toHex()}/${ | ||||||
|  |                                         record!!.position | ||||||
|  |                                     }" | ||||||
|  |                                     navController.navigate(route) | ||||||
|  |                                 } else { | ||||||
|  |                                     val route = "comic_page_route/${comic.id.toHex()}/${0}" | ||||||
|  |                                     navController.navigate(route) | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         }, modifier = Modifier.align(Alignment.CenterEnd)) | ||||||
|  |                         { | ||||||
|  |                             Text(text = "Continue", fontSize = 16.sp) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     HorizontalDivider(Modifier.padding(horizontal =  12.dp).padding(bottom = 4.dp), thickness = 1.5.dp) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 items(comicGridViewModel.chapterList) | ||||||
|  |                 { c -> | ||||||
|  |                     ChapterCard(comic, navController, c, comicGridViewModel) | ||||||
|  |                     HorizontalDivider(Modifier.padding(horizontal = 26.dp), thickness = 1.5.dp) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |     /* | ||||||
|  |             Card( | ||||||
|  |                 Modifier | ||||||
|  |                     .padding(horizontal = 16.dp) | ||||||
|  |                     .padding(top = 6.dp) | ||||||
|  |                     .padding(bottom = 20.dp) | ||||||
|  |                     .height(42.dp) | ||||||
|  |                     .clickable { | ||||||
|  |                         comicGridViewModel.updateProcess(comicId.hexToString()) | ||||||
|  |                         { | ||||||
|  |                             if (record != null) { | ||||||
|  |                                 val route = "comic_page_route/${comic.id.toHex()}/${ | ||||||
|  |                                     record!!.position | ||||||
|  |                                 }" | ||||||
|  |                                 navController.navigate(route) | ||||||
|  |                             } else { | ||||||
|  |                                 val route = "comic_page_route/${comic.id.toHex()}/${0}" | ||||||
|  |                                 navController.navigate(route) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                 colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||||
|  |                 shape = RoundedCornerShape(12.dp) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Box(Modifier.fillMaxSize()) { | ||||||
|  |                     Row( | ||||||
|  |                         Modifier | ||||||
|  |                             .fillMaxWidth() | ||||||
|  |                             .align(Alignment.Center) | ||||||
|  |                             .padding(horizontal = 8.dp) | ||||||
|  |                     ) { | ||||||
|  |                         if (record != null) { | ||||||
|  |                             val k = comic.getPageChapterIndex(record!!.position) | ||||||
|  |  | ||||||
|  |                             Text( | ||||||
|  |                                 text = "Last Read Position: ${k.first.name} ${k.second}/${ | ||||||
|  |                                     comic.getChapterLength( | ||||||
|  |                                         k.first.page | ||||||
|  |                                     ) | ||||||
|  |                                 }", | ||||||
|  |                                 fontSize = 20.sp, | ||||||
|  |                                 fontWeight = FontWeight.Bold, | ||||||
|  |                                 maxLines = 1, | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .padding(4.dp) | ||||||
|  |                                     .weight(1f) | ||||||
|  |                             ) | ||||||
|  |                         } else { | ||||||
|  |                             Text( | ||||||
|  |                                 text = "Read from scratch", | ||||||
|  |                                 fontSize = 20.sp, | ||||||
|  |                                 fontWeight = FontWeight.Bold, | ||||||
|  |                                 maxLines = 1, | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .padding(4.dp) | ||||||
|  |                                     .weight(1f) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |      */ | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun ChapterCard( | ||||||
|  |     comic: Comic, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     chapter: BookMark, | ||||||
|  |     comicGridViewModel: ComicGridViewModel = hiltViewModel<ComicGridViewModel>() | ||||||
|  | ) { | ||||||
|  |     val c = chapter | ||||||
|  |     val iv = comic.getPageIndex(c.page) | ||||||
|  |     val r = comic.comic.list.subList(iv, iv + comic.getChapterLength(c.page)) | ||||||
|  |  | ||||||
|  |     Card( | ||||||
|  |         colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(0.65f)), | ||||||
|  |         shape = RoundedCornerShape(6.dp), | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .wrapContentHeight() | ||||||
|  |             .padding(horizontal = 16.dp) | ||||||
|  |             .padding(vertical = 6.dp), | ||||||
|  |         onClick = { | ||||||
|  |             val route = "comic_page_route/${comic.id.toHex()}/${comic.getPageIndex(chapter.page)}" | ||||||
|  |             navController.navigate(route) | ||||||
|  |         } | ||||||
|  |     ) { | ||||||
|  |         Column(Modifier.fillMaxWidth()) | ||||||
|  |         { | ||||||
|  |             Text( | ||||||
|  |                 text = chapter.name, | ||||||
|  |                 fontSize = 16.sp, | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 maxLines = 2, | ||||||
|  |                 lineHeight = 18.sp, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(horizontal = 8.dp).padding(vertical = 4.dp) | ||||||
|  |                     .background(Color.Transparent) | ||||||
|  |             ) | ||||||
|  |             Text( | ||||||
|  |                 text = "${comic.getChapterLength(chapter.page)} Pages", | ||||||
|  |                 fontSize = 14.sp, | ||||||
|  |                 lineHeight = 16.sp, | ||||||
|  |                 maxLines = 1, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(horizontal = 8.dp) | ||||||
|  |                     .background(Color.Transparent) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             LazyRow( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .padding(horizontal = 8.dp).padding(vertical = 4.dp) | ||||||
|  |             ) { | ||||||
|  |                 items(r) | ||||||
|  |                 { r -> | ||||||
|  |                     Card( | ||||||
|  |                         shape = RoundedCornerShape(12.dp), | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .fillMaxWidth() | ||||||
|  |                             .wrapContentHeight() | ||||||
|  |                             .height(120.dp) | ||||||
|  |                             .padding(horizontal = 2.dp), | ||||||
|  |                         onClick = { | ||||||
|  |                             val route = | ||||||
|  |                                 "comic_page_route/${comic.id.toHex()}/${comic.getPageIndex(r)}" | ||||||
|  |                             navController.navigate(route) | ||||||
|  |                         } | ||||||
|  |                     ) { | ||||||
|  |                         AsyncImage( | ||||||
|  |                             model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                                 .data(comic.getPage(r, comicGridViewModel.apiClient)) | ||||||
|  |                                 .memoryCacheKey("${comic.id}/${r}") | ||||||
|  |                                 .diskCacheKey("${comic.id}/${r}") | ||||||
|  |                                 .build(), | ||||||
|  |                             contentDescription = null, | ||||||
|  |                             imageLoader = comicGridViewModel.imageLoader!!, | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .fillMaxSize() | ||||||
|  |                                 .clip(RoundedCornerShape(12.dp)), | ||||||
|  |                             contentScale = ContentScale.Fit, | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,355 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | import androidx.compose.animation.slideInVertically | ||||||
|  | import androidx.compose.animation.slideOutVertically | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.clickable | ||||||
|  | import androidx.compose.foundation.gestures.detectTapGestures | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.layout.widthIn | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.lazy.LazyRow | ||||||
|  | import androidx.compose.foundation.pager.HorizontalPager | ||||||
|  | import androidx.compose.foundation.pager.rememberPagerState | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.filled.Bookmarks | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.DisposableEffect | ||||||
|  | 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.graphics.Brush | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.input.pointer.pointerInput | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.platform.LocalView | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.BookMark | ||||||
|  | import com.acitelight.aether.setFullScreen | ||||||
|  | import com.acitelight.aether.view.components.BiliMiniSlider | ||||||
|  | import com.acitelight.aether.view.components.BookmarkPop | ||||||
|  | import com.acitelight.aether.viewModel.ComicPageViewModel | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun ComicPageView( | ||||||
|  |     comicId: String, | ||||||
|  |     page: String, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     comicPageViewModel: ComicPageViewModel = hiltViewModel<ComicPageViewModel>() | ||||||
|  | ) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     comicPageViewModel.Resolve(comicId.hexToString(), page.toInt()) | ||||||
|  |  | ||||||
|  |     val title by comicPageViewModel.title | ||||||
|  |     val pagerState = rememberPagerState( | ||||||
|  |         initialPage = page.toInt(), | ||||||
|  |         pageCount = { comicPageViewModel.pageList.size }) | ||||||
|  |     var showPlane by comicPageViewModel.showPlane | ||||||
|  |     var showBookMarkPop by remember { mutableStateOf(false) } | ||||||
|  |  | ||||||
|  |     comicPageViewModel.updateProcess(pagerState.currentPage) | ||||||
|  |  | ||||||
|  |     val comic by comicPageViewModel.comic | ||||||
|  |  | ||||||
|  |     val view = LocalView.current | ||||||
|  |     DisposableEffect(Unit) { | ||||||
|  |         setFullScreen(view, true) | ||||||
|  |         onDispose { | ||||||
|  |  | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     comic?.let { | ||||||
|  |         Box() | ||||||
|  |         { | ||||||
|  |             HorizontalPager( | ||||||
|  |                 state = pagerState, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxSize() | ||||||
|  |                     .align(Alignment.Center) | ||||||
|  |                     .background(Color.Black) | ||||||
|  |                     .pointerInput(Unit) { | ||||||
|  |                         detectTapGestures( | ||||||
|  |                             onTap = { | ||||||
|  |                                 showPlane = !showPlane | ||||||
|  |                                 if (showPlane) { | ||||||
|  |                                     comicPageViewModel.viewModelScope.launch { | ||||||
|  |                                         comicPageViewModel.listState?.scrollToItem(index = pagerState.currentPage) | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |             ) { page -> | ||||||
|  |                 AsyncImage( | ||||||
|  |                     model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                         .data(it.getPage(page, comicPageViewModel.apiClient)) | ||||||
|  |                         .memoryCacheKey("${it.id}/${page}") | ||||||
|  |                         .diskCacheKey("${it.id}/${page}") | ||||||
|  |                         .build(), | ||||||
|  |                     contentDescription = null, | ||||||
|  |                     imageLoader = comicPageViewModel.imageLoader!!, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .fillMaxSize(), | ||||||
|  |                     contentScale = ContentScale.Fit, | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = showPlane, | ||||||
|  |                 enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |                 exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.TopCenter) | ||||||
|  |             ) { | ||||||
|  |                 Column(Modifier | ||||||
|  |                     .align(Alignment.TopCenter) | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .background( | ||||||
|  |                         brush = Brush.verticalGradient( | ||||||
|  |                             colors = listOf( | ||||||
|  |                                 Color.Black.copy(alpha = 0.9f), | ||||||
|  |                                 Color.Transparent, | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     )) | ||||||
|  |                 { | ||||||
|  |                     Row(modifier = Modifier | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .padding(horizontal =  16.dp).padding(top = 16.dp)) | ||||||
|  |                     { | ||||||
|  |                         Text( | ||||||
|  |                             text = title, | ||||||
|  |                             fontSize = 16.sp, | ||||||
|  |                             lineHeight = 19.sp, | ||||||
|  |                             fontWeight = FontWeight.Bold, | ||||||
|  |                             maxLines = 1, | ||||||
|  |                             color = Color.White, | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .padding(8.dp) | ||||||
|  |                                 .padding(horizontal = 10.dp) | ||||||
|  |                                 .weight(1f) | ||||||
|  |                                 .align(Alignment.CenterVertically) | ||||||
|  |                         ) | ||||||
|  |  | ||||||
|  |                         Text( | ||||||
|  |                             text = "${pagerState.currentPage + 1}/${pagerState.pageCount}", | ||||||
|  |                             fontSize = 16.sp, | ||||||
|  |                             lineHeight = 19.sp, | ||||||
|  |                             fontWeight = FontWeight.Bold, | ||||||
|  |                             maxLines = 1, | ||||||
|  |                             color = Color.White, | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .padding(8.dp) | ||||||
|  |                                 .widthIn(min = 60.dp) | ||||||
|  |                                 .align(Alignment.CenterVertically) | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                     Box(Modifier.fillMaxWidth() | ||||||
|  |                         .padding(horizontal = 16.dp)) | ||||||
|  |                     { | ||||||
|  |                         Row { | ||||||
|  |                             val k = it.getPageChapterIndex(pagerState.currentPage) | ||||||
|  |                             Text( | ||||||
|  |                                 text = k.first.name, | ||||||
|  |                                 fontSize = 16.sp, | ||||||
|  |                                 fontWeight = FontWeight.Bold, | ||||||
|  |                                 maxLines = 1, | ||||||
|  |                                 color = Color.White, | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .padding(8.dp) | ||||||
|  |                                     .padding(horizontal = 10.dp) | ||||||
|  |                                     .align(Alignment.CenterVertically) | ||||||
|  |                             ) | ||||||
|  |  | ||||||
|  |                             Text( | ||||||
|  |                                 text = "${k.second}/${it.getChapterLength(k.first.page)}", | ||||||
|  |                                 fontSize = 18.sp, | ||||||
|  |                                 fontWeight = FontWeight.Bold, | ||||||
|  |                                 maxLines = 1, | ||||||
|  |                                 color = Color.White, | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .padding(8.dp) | ||||||
|  |                                     .widthIn(min = 60.dp) | ||||||
|  |                                     .align(Alignment.CenterVertically) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |                         Card( | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .align(Alignment.CenterEnd) | ||||||
|  |                                 .padding(top = 6.dp) | ||||||
|  |                                 .padding(horizontal = 12.dp) | ||||||
|  |                                 .height(42.dp), | ||||||
|  |                             colors = CardDefaults.cardColors(containerColor = colorScheme.surface), | ||||||
|  |                             shape = RoundedCornerShape(12.dp) | ||||||
|  |                         ) | ||||||
|  |                         { | ||||||
|  |                             Box(Modifier.clickable { | ||||||
|  |                                 showBookMarkPop = true | ||||||
|  |                             }) { | ||||||
|  |                                 Icon( | ||||||
|  |                                     Icons.Filled.Bookmarks, | ||||||
|  |                                     modifier = Modifier | ||||||
|  |                                         .padding(8.dp), | ||||||
|  |                                     contentDescription = "Bookmark" | ||||||
|  |                                 ) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     Spacer(Modifier.height(64.dp)) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = showPlane, | ||||||
|  |                 enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }), | ||||||
|  |                 exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }), | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.BottomCenter) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 val k = it.getPageChapterIndex(pagerState.currentPage) | ||||||
|  |                 Column(Modifier | ||||||
|  |                     .background( | ||||||
|  |                         brush = Brush.verticalGradient( | ||||||
|  |                             colors = listOf( | ||||||
|  |                                 Color.Transparent, | ||||||
|  |                                 Color.Black.copy(alpha = 0.9f), | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     )) { | ||||||
|  |                     Spacer(Modifier.height(42.dp)) | ||||||
|  |                     LazyRow( | ||||||
|  |                         horizontalArrangement = Arrangement.spacedBy(5.dp), | ||||||
|  |                         state = comicPageViewModel.listState!!, modifier = Modifier | ||||||
|  |                             .fillMaxWidth() | ||||||
|  |                             .padding(bottom = 1.dp) | ||||||
|  |                             .padding(horizontal = 12.dp) | ||||||
|  |                             .height(180.dp) | ||||||
|  |                     ) | ||||||
|  |                     { | ||||||
|  |                         items(comicPageViewModel.pageList.size) | ||||||
|  |                         { r -> | ||||||
|  |                             Card( | ||||||
|  |                                 colors = CardDefaults.cardColors(containerColor = colorScheme.primary.copy(0.8f)), | ||||||
|  |                                 shape = RoundedCornerShape(8.dp), | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .fillMaxHeight() | ||||||
|  |                                     .wrapContentHeight() | ||||||
|  |                                     .padding(vertical = 8.dp), | ||||||
|  |                                 onClick = { | ||||||
|  |                                     pagerState.requestScrollToPage(page = r) | ||||||
|  |                                 } | ||||||
|  |                             ) { | ||||||
|  |                                 Box(Modifier.padding(0.dp)) | ||||||
|  |                                 { | ||||||
|  |                                     AsyncImage( | ||||||
|  |                                         model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                                             .data(it.getPage(r, comicPageViewModel.apiClient)) | ||||||
|  |                                             .memoryCacheKey("${it.id}/${r}") | ||||||
|  |                                             .diskCacheKey("${it.id}/${r}") | ||||||
|  |                                             .build(), | ||||||
|  |                                         contentDescription = null, | ||||||
|  |                                         imageLoader = comicPageViewModel.imageLoader!!, | ||||||
|  |                                         modifier = Modifier | ||||||
|  |                                             .fillMaxHeight() | ||||||
|  |                                             .clip(RoundedCornerShape(8.dp)) | ||||||
|  |                                             .align(Alignment.Center), | ||||||
|  |                                         contentScale = ContentScale.Fit, | ||||||
|  |                                     ) | ||||||
|  |                                     val k = it.getPageChapterIndex(r) | ||||||
|  |                                     Box( | ||||||
|  |                                         Modifier | ||||||
|  |                                             .align(Alignment.TopEnd) | ||||||
|  |                                             .padding(6.dp) | ||||||
|  |                                             .background( | ||||||
|  |                                                 Color.Black.copy(alpha = 0.65f), | ||||||
|  |                                                 shape = RoundedCornerShape(12.dp) | ||||||
|  |                                             ) | ||||||
|  |                                     ) | ||||||
|  |                                     { | ||||||
|  |                                         Row { | ||||||
|  |                                             Text( | ||||||
|  |                                                 text = "${k.second}/${it.getChapterLength(k.first.page)}", | ||||||
|  |                                                 fontSize = 16.sp, | ||||||
|  |                                                 fontWeight = FontWeight.Bold, | ||||||
|  |                                                 color = Color.White, | ||||||
|  |                                                 maxLines = 1, | ||||||
|  |                                                 modifier = Modifier | ||||||
|  |                                                     .padding(2.dp) | ||||||
|  |                                                     .align(Alignment.CenterVertically) | ||||||
|  |                                             ) | ||||||
|  |                                         } | ||||||
|  |                                     } | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     BiliMiniSlider( | ||||||
|  |                         value = (k.second.toInt()) / it.getChapterLength(k.first.page).toFloat(), | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .height(6.dp) | ||||||
|  |                             .fillMaxWidth().padding(horizontal = 24.dp) | ||||||
|  |                             .fillMaxWidth(), | ||||||
|  |                         onValueChange = { | ||||||
|  |  | ||||||
|  |                         } | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |                     Spacer(Modifier.height(24.dp)) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (showBookMarkPop) { | ||||||
|  |         BookmarkPop({ | ||||||
|  |             showBookMarkPop = false | ||||||
|  |         }, { s -> | ||||||
|  |             showBookMarkPop = false | ||||||
|  |             comicPageViewModel.viewModelScope.launch { | ||||||
|  |                 comicPageViewModel.mediaManager.postBookmark( | ||||||
|  |                     comicId.hexToString(), | ||||||
|  |                     BookMark(name = s, page = comicPageViewModel.pageList[pagerState.currentPage]) | ||||||
|  |                 ) | ||||||
|  |                 comicPageViewModel.comic.value = | ||||||
|  |                     comicPageViewModel.mediaManager.queryComicInfoSingle(comicId.hexToString()) | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,244 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | 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.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.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.size | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.layout.widthIn | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.items | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState | ||||||
|  | import androidx.compose.foundation.rememberScrollState | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.foundation.text.BasicTextField | ||||||
|  | import androidx.compose.foundation.verticalScroll | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.filled.Search | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.LocalTextStyle | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.setValue | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.layout.Layout | ||||||
|  | import androidx.compose.ui.layout.Placeable | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.text.style.TextAlign | ||||||
|  | import androidx.compose.ui.unit.Dp | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import com.acitelight.aether.view.components.ComicCard | ||||||
|  | import com.acitelight.aether.viewModel.ComicScreenViewModel | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VariableGrid( | ||||||
|  |     modifier: Modifier = Modifier, | ||||||
|  |     rowHeight: Dp, | ||||||
|  |     horizontalSpacing: Dp = 4.dp, | ||||||
|  |     verticalSpacing: Dp = 4.dp, | ||||||
|  |     content: @Composable () -> Unit | ||||||
|  | ) { | ||||||
|  |     val scrollState = rememberScrollState() | ||||||
|  |  | ||||||
|  |     Layout( | ||||||
|  |         modifier = modifier | ||||||
|  |             .verticalScroll(scrollState), | ||||||
|  |         content = content | ||||||
|  |     ) { measurables, constraints -> | ||||||
|  |  | ||||||
|  |         val rowHeightPx = rowHeight.roundToPx() | ||||||
|  |         val hSpacePx = horizontalSpacing.roundToPx() | ||||||
|  |         val vSpacePx = verticalSpacing.roundToPx() | ||||||
|  |  | ||||||
|  |         val placeables = measurables.map { measurable -> | ||||||
|  |             measurable.measure( | ||||||
|  |                 constraints.copy( | ||||||
|  |                     minWidth = 0, | ||||||
|  |                     minHeight = rowHeightPx, | ||||||
|  |                     maxHeight = rowHeightPx | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val rows = mutableListOf<List<Placeable>>() | ||||||
|  |         var currentRow = mutableListOf<Placeable>() | ||||||
|  |         var currentWidth = 0 | ||||||
|  |         val maxWidth = constraints.maxWidth | ||||||
|  |  | ||||||
|  |         for (placeable in placeables) { | ||||||
|  |             if (currentRow.isNotEmpty() && currentWidth + placeable.width + hSpacePx > maxWidth) { | ||||||
|  |                 rows.add(currentRow) | ||||||
|  |                 currentRow = mutableListOf() | ||||||
|  |                 currentWidth = 0 | ||||||
|  |             } | ||||||
|  |             currentRow.add(placeable) | ||||||
|  |             currentWidth += placeable.width + hSpacePx | ||||||
|  |         } | ||||||
|  |         if (currentRow.isNotEmpty()) { | ||||||
|  |             rows.add(currentRow) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val layoutHeight = if (rows.isEmpty()) { | ||||||
|  |             0 | ||||||
|  |         } else { | ||||||
|  |             rows.size * rowHeightPx + (rows.size - 1) * vSpacePx | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         layout( | ||||||
|  |             width = constraints.maxWidth.coerceAtLeast(constraints.minWidth), | ||||||
|  |             height = layoutHeight.coerceAtLeast(constraints.minHeight) | ||||||
|  |         ) { | ||||||
|  |             var y = 0 | ||||||
|  |             for (row in rows) { | ||||||
|  |                 var x = 0 | ||||||
|  |                 for (placeable in row) { | ||||||
|  |                     placeable.placeRelative(x, y) | ||||||
|  |                     x += placeable.width + hSpacePx | ||||||
|  |                 } | ||||||
|  |                 y += rowHeightPx + vSpacePx | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun ComicScreen( | ||||||
|  |     navController: NavHostController, | ||||||
|  |     comicScreenViewModel: ComicScreenViewModel = hiltViewModel<ComicScreenViewModel>() | ||||||
|  | ) { | ||||||
|  |     val included = comicScreenViewModel.included | ||||||
|  |     val state = rememberLazyStaggeredGridState() | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     var searchFilter by comicScreenViewModel.searchFilter | ||||||
|  |  | ||||||
|  |     Column { | ||||||
|  |         Row( | ||||||
|  |             Modifier | ||||||
|  |                 .padding(4.dp) | ||||||
|  |                 .align(Alignment.CenterHorizontally) | ||||||
|  |         ) { | ||||||
|  |             Text( | ||||||
|  |                 text = "Comics", | ||||||
|  |                 style = MaterialTheme.typography.headlineMedium, | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterVertically) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             Spacer(Modifier.weight(1f)) | ||||||
|  |  | ||||||
|  |             Row( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.CenterVertically) | ||||||
|  |                     .height(36.dp) | ||||||
|  |                     .widthIn(max = 240.dp) | ||||||
|  |                     .background(colorScheme.primary, RoundedCornerShape(8.dp)) | ||||||
|  |                     .padding(horizontal = 6.dp) | ||||||
|  |             ) { | ||||||
|  |                 Icon( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .size(30.dp) | ||||||
|  |                         .align(Alignment.CenterVertically), | ||||||
|  |                     imageVector = Icons.Default.Search, | ||||||
|  |                     contentDescription = "Catalogue" | ||||||
|  |                 ) | ||||||
|  |                 Spacer(Modifier.width(4.dp)) | ||||||
|  |                 BasicTextField( | ||||||
|  |                     value = searchFilter, | ||||||
|  |                     onValueChange = { searchFilter = it }, | ||||||
|  |                     textStyle = LocalTextStyle.current.copy( | ||||||
|  |                         fontSize = 18.sp, | ||||||
|  |                         color = Color.White, | ||||||
|  |                         textAlign = TextAlign.Start | ||||||
|  |                     ), | ||||||
|  |                     singleLine = true, | ||||||
|  |                     modifier = Modifier.align(Alignment.CenterVertically) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         VariableGrid( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .heightIn(max = 72.dp) | ||||||
|  |                 .padding(4.dp), | ||||||
|  |             rowHeight = 30.dp | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             for (i in comicScreenViewModel.tags) { | ||||||
|  |  | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .background( | ||||||
|  |                             if (included.contains(i)) Color.Green.copy(alpha = 0.65f) else colorScheme.surface, | ||||||
|  |                             shape = RoundedCornerShape(4.dp) | ||||||
|  |                         ) | ||||||
|  |                         .height(32.dp) | ||||||
|  |                         .widthIn(max = 72.dp) | ||||||
|  |                         .clickable { | ||||||
|  |                             if (included.contains(i)) | ||||||
|  |                                 included.remove(i) | ||||||
|  |                             else | ||||||
|  |                                 included.add(i) | ||||||
|  |                         } | ||||||
|  |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         text = i, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         fontSize = 16.sp, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(2.dp) | ||||||
|  |                             .align(Alignment.Center) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         HorizontalDivider(Modifier.padding(1.dp), thickness = 1.5.dp) | ||||||
|  |  | ||||||
|  |         LazyVerticalStaggeredGrid( | ||||||
|  |             columns = StaggeredGridCells.Adaptive(120.dp), | ||||||
|  |             contentPadding = PaddingValues(4.dp), | ||||||
|  |             verticalItemSpacing = 6.dp, | ||||||
|  |             horizontalArrangement = Arrangement.spacedBy(4.dp), | ||||||
|  |             state = state, | ||||||
|  |             modifier = Modifier.fillMaxSize() | ||||||
|  |         ) { | ||||||
|  |             items( | ||||||
|  |                 items = comicScreenViewModel.comics | ||||||
|  |                     .filter { searchFilter.isEmpty() || searchFilter in it.comic.comic_name } | ||||||
|  |                     .filter { x -> | ||||||
|  |                         included.all { y -> y in x.comic.tags } || included.isEmpty() | ||||||
|  |                     }, | ||||||
|  |                 key = { it.id } | ||||||
|  |             ) { comic -> | ||||||
|  |                 Box( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .wrapContentHeight() | ||||||
|  |                 ) { | ||||||
|  |                     ComicCard(comic, navController, comicScreenViewModel) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										215
									
								
								app/src/main/java/com/acitelight/aether/view/pages/HomeScreen.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,215 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | 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.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.grid.GridCells | ||||||
|  | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | ||||||
|  | import androidx.compose.foundation.lazy.grid.items | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.foundation.pager.HorizontalPager | ||||||
|  | import androidx.compose.foundation.pager.rememberPagerState | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.DividerDefaults | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.alpha | ||||||
|  | import androidx.compose.ui.graphics.Brush | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.Global.updateRelate | ||||||
|  | import com.acitelight.aether.model.Comic | ||||||
|  | import com.acitelight.aether.view.components.MiniVideoCard | ||||||
|  | import com.acitelight.aether.viewModel.HomeScreenViewModel | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun HomeScreen( | ||||||
|  |     homeScreenViewModel: HomeScreenViewModel = hiltViewModel<HomeScreenViewModel>(), | ||||||
|  |     navController: NavHostController | ||||||
|  | ) { | ||||||
|  |     val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 }) | ||||||
|  |  | ||||||
|  |     HorizontalPager( | ||||||
|  |         state = pagerState, | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxSize() | ||||||
|  |     ) { p -> | ||||||
|  |         if (p == 0) { | ||||||
|  |             Column(Modifier.fillMaxHeight()) { | ||||||
|  |                 Text( | ||||||
|  |                     text = "Videos", | ||||||
|  |                     style = MaterialTheme.typography.headlineMedium, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .align(Alignment.Start) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) | ||||||
|  |  | ||||||
|  |                 LazyColumn(modifier = Modifier.fillMaxWidth()) | ||||||
|  |                 { | ||||||
|  |                     items(homeScreenViewModel.recentManager.recentVideo) | ||||||
|  |                     { i -> | ||||||
|  |                         MiniVideoCard( | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .padding(horizontal = 12.dp), | ||||||
|  |                             i, | ||||||
|  |                             apiClient = homeScreenViewModel.apiClient, | ||||||
|  |                             imageLoader = homeScreenViewModel.imageLoader!! | ||||||
|  |                         ) | ||||||
|  |                         { | ||||||
|  |                             updateRelate(homeScreenViewModel.recentManager.recentVideo, i) | ||||||
|  |  | ||||||
|  |                             val playList = mutableListOf<String>() | ||||||
|  |                             val fv = homeScreenViewModel.videoLibrary.classesMap.map { it.value } | ||||||
|  |                                 .flatten() | ||||||
|  |  | ||||||
|  |                             val group = | ||||||
|  |                                 fv.filter { it.klass == i.klass && it.video.group == i.video.group && it.video.group != "null" } | ||||||
|  |                             for (ix in group) { | ||||||
|  |                                 playList.add("${ix.klass}/${ix.id}") | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                             if(!playList.contains("${i.klass}/${i.id}")) | ||||||
|  |                                 playList.add("${i.klass}/${i.id}") | ||||||
|  |  | ||||||
|  |                             val route = | ||||||
|  |                                 "video_player_route/${(playList.joinToString(",") + "|${i.id}").toHex()}" | ||||||
|  |                             navController.navigate(route) | ||||||
|  |                         } | ||||||
|  |                         HorizontalDivider( | ||||||
|  |                             Modifier | ||||||
|  |                                 .padding(vertical = 8.dp) | ||||||
|  |                                 .alpha(0.4f), | ||||||
|  |                             1.dp, | ||||||
|  |                             DividerDefaults.color | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             Column(Modifier.fillMaxHeight()) { | ||||||
|  |                 Text( | ||||||
|  |                     text = "Comics", | ||||||
|  |                     style = MaterialTheme.typography.headlineMedium, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .align(Alignment.Start) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) | ||||||
|  |  | ||||||
|  |                 LazyVerticalGrid( | ||||||
|  |                     columns = GridCells.Adaptive(128.dp), | ||||||
|  |                     contentPadding = PaddingValues(8.dp), | ||||||
|  |                     verticalArrangement = Arrangement.spacedBy(8.dp), | ||||||
|  |                     horizontalArrangement = Arrangement.spacedBy(8.dp), | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     items(homeScreenViewModel.recentManager.recentComic) | ||||||
|  |                     { comic -> | ||||||
|  |                         ComicCardRecent(comic, navController, homeScreenViewModel) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun ComicCardRecent( | ||||||
|  |     comic: Comic, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     homeScreenViewModel: HomeScreenViewModel | ||||||
|  | ) { | ||||||
|  |     Card( | ||||||
|  |         shape = RoundedCornerShape(6.dp), | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .wrapContentHeight(), | ||||||
|  |         onClick = { | ||||||
|  |             val route = "comic_grid_route/${comic.id.toHex()}" | ||||||
|  |             navController.navigate(route) | ||||||
|  |         } | ||||||
|  |     ) { | ||||||
|  |         Column( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |         ) { | ||||||
|  |             Box(modifier = Modifier.fillMaxSize()) { | ||||||
|  |                 AsyncImage( | ||||||
|  |                     model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                         .data(comic.getPage(0, homeScreenViewModel.apiClient)) | ||||||
|  |                         .memoryCacheKey("${comic.id}/${0}") | ||||||
|  |                         .diskCacheKey("${comic.id}/${0}") | ||||||
|  |                         .build(), | ||||||
|  |                     contentDescription = null, | ||||||
|  |                     imageLoader = homeScreenViewModel.imageLoader!!, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .fillMaxSize(), | ||||||
|  |                     contentScale = ContentScale.Crop, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .height(24.dp) | ||||||
|  |                         .background( | ||||||
|  |                             brush = Brush.verticalGradient( | ||||||
|  |                                 colors = listOf( | ||||||
|  |                                     Color.Transparent, | ||||||
|  |                                     Color.Black.copy(alpha = 0.45f) | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                         .align(Alignment.BottomCenter) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .align(Alignment.BottomEnd) | ||||||
|  |                             .padding(2.dp), | ||||||
|  |                         fontSize = 12.sp, | ||||||
|  |                         text = "${comic.comic.list.size} Pages", | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         color = Color.White | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             Text( | ||||||
|  |                 text = comic.comic.comic_name, | ||||||
|  |                 fontSize = 14.sp, | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 maxLines = 2, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(8.dp) | ||||||
|  |                     .background(Color.Transparent) | ||||||
|  |                     .heightIn(48.dp) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,13 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import com.acitelight.aether.viewModel.LiveScreenViewModel | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun LiveScreen( | ||||||
|  |     liveScreenViewModel: LiveScreenViewModel = hiltViewModel<LiveScreenViewModel>() | ||||||
|  | ) | ||||||
|  | { | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -1,22 +1,24 @@ | |||||||
| package com.acitelight.aether.view | package com.acitelight.aether.view.pages | ||||||
| 
 | 
 | ||||||
| import android.widget.Toast |  | ||||||
| import androidx.compose.foundation.layout.Arrangement | import androidx.compose.foundation.layout.Arrangement | ||||||
| import androidx.compose.foundation.layout.Column | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
| import androidx.compose.foundation.layout.Spacer | import androidx.compose.foundation.layout.Spacer | ||||||
| import androidx.compose.foundation.layout.fillMaxSize | import androidx.compose.foundation.layout.fillMaxSize | ||||||
| import androidx.compose.foundation.layout.fillMaxWidth | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
| import androidx.compose.foundation.layout.height | import androidx.compose.foundation.layout.height | ||||||
| import androidx.compose.foundation.layout.padding | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
| import androidx.compose.foundation.lazy.LazyColumn | import androidx.compose.foundation.lazy.LazyColumn | ||||||
| import androidx.compose.foundation.text.KeyboardOptions | import androidx.compose.foundation.text.KeyboardOptions | ||||||
| import androidx.compose.material.icons.Icons | import androidx.compose.material.icons.Icons | ||||||
| import androidx.compose.material.icons.filled.Key | import androidx.compose.material.icons.filled.Key | ||||||
| import androidx.compose.material.icons.filled.Link | import androidx.compose.material.icons.filled.Link | ||||||
| import androidx.compose.material.icons.filled.Person | import androidx.compose.material.icons.filled.Person | ||||||
| import androidx.compose.material.icons.filled.Security | import androidx.compose.material.icons.filled.Textsms | ||||||
| import androidx.compose.material3.Button | import androidx.compose.material3.Button | ||||||
| import androidx.compose.material3.Card | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.Checkbox | ||||||
| import androidx.compose.material3.Icon | import androidx.compose.material3.Icon | ||||||
| import androidx.compose.material3.MaterialTheme | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.OutlinedTextField | import androidx.compose.material3.OutlinedTextField | ||||||
| @@ -27,21 +29,22 @@ import androidx.compose.runtime.getValue | |||||||
| import androidx.compose.runtime.setValue | import androidx.compose.runtime.setValue | ||||||
| import androidx.compose.ui.Alignment | import androidx.compose.ui.Alignment | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| import androidx.compose.ui.platform.LocalContext |  | ||||||
| import androidx.compose.ui.text.TextStyle | import androidx.compose.ui.text.TextStyle | ||||||
| import androidx.compose.ui.text.input.KeyboardType | import androidx.compose.ui.text.input.KeyboardType | ||||||
| import androidx.compose.ui.unit.dp | import androidx.compose.ui.unit.dp | ||||||
| import androidx.compose.ui.unit.sp | import androidx.compose.ui.unit.sp | ||||||
| import androidx.lifecycle.viewmodel.compose.viewModel | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
| import com.acitelight.aether.viewModel.MeScreenViewModel | import com.acitelight.aether.viewModel.MeScreenViewModel | ||||||
| 
 | 
 | ||||||
| @Composable | @Composable | ||||||
| fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) { | fun MeScreen(meScreenViewModel: MeScreenViewModel = hiltViewModel<MeScreenViewModel>()) { | ||||||
|     val context = LocalContext.current |     var username by meScreenViewModel.username | ||||||
|     var username by meScreenViewModel.username; |     var privateKey by meScreenViewModel.privateKey | ||||||
|     var privateKey by meScreenViewModel.privateKey; |  | ||||||
|     var url by meScreenViewModel.url |     var url by meScreenViewModel.url | ||||||
|     var cert by meScreenViewModel.cert |     var cert by meScreenViewModel.cert | ||||||
|  |     var pak by meScreenViewModel.pak | ||||||
|  | 
 | ||||||
|  |     val uss by meScreenViewModel.uss.collectAsState(initial = false) | ||||||
| 
 | 
 | ||||||
|     LazyColumn( |     LazyColumn( | ||||||
|         modifier = Modifier |         modifier = Modifier | ||||||
| @@ -49,7 +52,8 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) { | |||||||
|             .padding(8.dp), |             .padding(8.dp), | ||||||
|         horizontalAlignment = Alignment.CenterHorizontally, |         horizontalAlignment = Alignment.CenterHorizontally, | ||||||
|         verticalArrangement = Arrangement.Top |         verticalArrangement = Arrangement.Top | ||||||
|     ) { |     ) | ||||||
|  |     { | ||||||
|         // Card component for a clean, contained UI block |         // Card component for a clean, contained UI block | ||||||
|         item{ |         item{ | ||||||
|             Card( |             Card( | ||||||
| @@ -104,7 +108,7 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) { | |||||||
|                     // Save Button |                     // Save Button | ||||||
|                     Button( |                     Button( | ||||||
|                         onClick = { |                         onClick = { | ||||||
|                             meScreenViewModel.updateAccount(username, privateKey, context) |                             meScreenViewModel.updateAccount(username, privateKey) | ||||||
|                         }, |                         }, | ||||||
|                         modifier = Modifier.fillMaxWidth(), |                         modifier = Modifier.fillMaxWidth(), | ||||||
|                         enabled = privateKey != "******" |                         enabled = privateKey != "******" | ||||||
| @@ -134,6 +138,8 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) { | |||||||
|                             .align(Alignment.Start) |                             .align(Alignment.Start) | ||||||
|                     ) |                     ) | ||||||
| 
 | 
 | ||||||
|  |                     Spacer(modifier = Modifier.width(8.dp)) | ||||||
|  | 
 | ||||||
|                     // Username input field |                     // Username input field | ||||||
|                     OutlinedTextField( |                     OutlinedTextField( | ||||||
|                         value = url, |                         value = url, | ||||||
| @@ -146,32 +152,98 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) { | |||||||
|                         modifier = Modifier.fillMaxWidth() |                         modifier = Modifier.fillMaxWidth() | ||||||
|                     ) |                     ) | ||||||
| 
 | 
 | ||||||
|                     Spacer(modifier = Modifier.height(16.dp)) |                     Spacer(modifier = Modifier.height(4.dp)) | ||||||
| 
 | 
 | ||||||
|                     // Private key input field |                     Row(Modifier.align(Alignment.Start)) { | ||||||
|                     OutlinedTextField( |                         Checkbox( | ||||||
|                         value = cert, |                             checked = uss, | ||||||
|                         onValueChange = { cert = it }, |                             onCheckedChange = { isChecked -> | ||||||
|                         label = { Text("Cert") }, |                                 meScreenViewModel.onUseSelfSignedCheckedChange(isChecked) | ||||||
|                         singleLine = false, |                             }, | ||||||
|                         maxLines = 40, |                             modifier = Modifier.align(Alignment.CenterVertically) | ||||||
|                         minLines = 20, |                         ) | ||||||
|                         modifier = Modifier.fillMaxWidth(), |                         Spacer(modifier = Modifier.width(4.dp)) | ||||||
|                         textStyle = TextStyle( |                         Text( | ||||||
|                             fontSize = 8.sp |                             text = "Use Self-Signed Cert", | ||||||
|  |                             modifier = Modifier.align(Alignment.CenterVertically) | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                     Spacer(modifier = Modifier.height(4.dp)) | ||||||
|  |                     // Private key input field | ||||||
|  |                     if (uss) | ||||||
|  |                         OutlinedTextField( | ||||||
|  |                             value = cert, | ||||||
|  |                             onValueChange = { cert = it }, | ||||||
|  |                             label = { Text("Cert") }, | ||||||
|  |                             singleLine = false, | ||||||
|  |                             maxLines = 40, | ||||||
|  |                             minLines = 20, | ||||||
|  |                             modifier = Modifier.fillMaxWidth(), | ||||||
|  |                             textStyle = TextStyle( | ||||||
|  |                                 fontSize = 8.sp | ||||||
|  |                             ) | ||||||
|                         ) |                         ) | ||||||
|                     ) |  | ||||||
| 
 | 
 | ||||||
|                     Spacer(modifier = Modifier.height(24.dp)) |                     Spacer(modifier = Modifier.height(24.dp)) | ||||||
| 
 | 
 | ||||||
|                     // Save Button |                     // Save Button | ||||||
|                     Button( |                     Row{ | ||||||
|                         onClick = { |                         Button( | ||||||
|                             meScreenViewModel.updateServer(url, cert, context) |                             onClick = { | ||||||
|  |                                 meScreenViewModel.updateServer(url, cert) | ||||||
|  |                             }, | ||||||
|  |                             modifier = Modifier.weight(0.5f).padding(8.dp) | ||||||
|  |                         ) { | ||||||
|  |                             Text("Save") | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             Card( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .padding(8.dp) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Column( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .padding(16.dp) | ||||||
|  |                         .fillMaxWidth(), | ||||||
|  |                     horizontalAlignment = Alignment.CenterHorizontally | ||||||
|  |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         text = "Toolbox", | ||||||
|  |                         style = MaterialTheme.typography.headlineMedium, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(bottom = 16.dp) | ||||||
|  |                             .align(Alignment.Start) | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                     Spacer(modifier = Modifier.width(8.dp)) | ||||||
|  | 
 | ||||||
|  |                     OutlinedTextField( | ||||||
|  |                         value = pak, | ||||||
|  |                         onValueChange = { pak = it }, | ||||||
|  |                         label = { Text("Packet") }, | ||||||
|  |                         leadingIcon = { | ||||||
|  |                             Icon(Icons.Default.Textsms, contentDescription = "Packet") | ||||||
|                         }, |                         }, | ||||||
|  |                         singleLine = true, | ||||||
|                         modifier = Modifier.fillMaxWidth() |                         modifier = Modifier.fillMaxWidth() | ||||||
|                     ) { |                     ) | ||||||
|                         Text("Save") | 
 | ||||||
|  |                     Spacer(modifier = Modifier.height(8.dp)) | ||||||
|  | 
 | ||||||
|  |                     Row{ | ||||||
|  |                         Button( | ||||||
|  |                             onClick = { | ||||||
|  |                                 meScreenViewModel.sendPacket(pak) | ||||||
|  |                             }, | ||||||
|  |                             modifier = Modifier.weight(0.5f).padding(8.dp) | ||||||
|  |                         ) { | ||||||
|  |                             Text("Send") | ||||||
|  |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| @@ -0,0 +1,138 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.material3.DividerDefaults | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import com.acitelight.aether.model.VideoDownloadItemState | ||||||
|  | import com.acitelight.aether.view.components.BiliMiniSlider | ||||||
|  | import com.acitelight.aether.view.components.VideoDownloadCardMini | ||||||
|  | import com.acitelight.aether.viewModel.TransmissionScreenViewModel | ||||||
|  | import com.tonyodev.fetch2.Status | ||||||
|  | import kotlin.collections.sortedWith | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun TransmissionScreen( | ||||||
|  |     navigator: NavHostController, | ||||||
|  |     transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel<TransmissionScreenViewModel>() | ||||||
|  | ) { | ||||||
|  |     val downloads = transmissionScreenViewModel.downloads | ||||||
|  |     Column() | ||||||
|  |     { | ||||||
|  |         Text( | ||||||
|  |             text = "Video Tasks", | ||||||
|  |             style = MaterialTheme.typography.headlineMedium, | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .padding(8.dp) | ||||||
|  |                 .align(Alignment.Start) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         Text( | ||||||
|  |             text = "All: ${downloads.count { it.type == "main" }}", | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .padding(horizontal = 8.dp) | ||||||
|  |                 .align(Alignment.Start), | ||||||
|  |             fontSize = 12.sp, | ||||||
|  |             lineHeight = 13.sp, | ||||||
|  |             maxLines = 1 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         Text( | ||||||
|  |             text = "Completed: ${downloads.count { it.type == "main" && it.status == Status.COMPLETED }}", | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .padding(horizontal = 8.dp) | ||||||
|  |                 .align(Alignment.Start), | ||||||
|  |             fontSize = 12.sp, | ||||||
|  |             lineHeight = 13.sp, | ||||||
|  |             maxLines = 1 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         val downloading = downloads.filter { it.status == Status.DOWNLOADING } | ||||||
|  |         BiliMiniSlider( | ||||||
|  |             value = if (downloading.sumOf { it.totalBytes } == 0L) 1f else downloading.sumOf { it.downloadedBytes } / downloading.sumOf { it.totalBytes } | ||||||
|  |                 .toFloat(), | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .height(6.dp) | ||||||
|  |                 .align(Alignment.End) | ||||||
|  |                 .fillMaxWidth(), | ||||||
|  |             onValueChange = { | ||||||
|  |  | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) | ||||||
|  |  | ||||||
|  |         LazyColumn( | ||||||
|  |             modifier = Modifier.fillMaxWidth() | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             items( | ||||||
|  |                 downloads | ||||||
|  |                 .filter { it.type == "main" } | ||||||
|  |                 .sortedWith(compareBy(naturalOrder()) { it.fileName }) | ||||||
|  |                 .sortedBy { it.status == Status.COMPLETED }, key = { it.id }) | ||||||
|  |             { item -> | ||||||
|  |                 VideoDownloadCardMini( | ||||||
|  |                     navigator = navigator, | ||||||
|  |                     viewModel = transmissionScreenViewModel, | ||||||
|  |                     model = item, | ||||||
|  |                     onPause = { | ||||||
|  |                         for (i in downloadToGroup( | ||||||
|  |                             item, | ||||||
|  |                             downloads | ||||||
|  |                         )) transmissionScreenViewModel.pause(i.id) | ||||||
|  |                     }, | ||||||
|  |                     onResume = { | ||||||
|  |                         for (i in downloadToGroup( | ||||||
|  |                             item, | ||||||
|  |                             downloads | ||||||
|  |                         )) transmissionScreenViewModel.resume(i.id) | ||||||
|  |                     }, | ||||||
|  |                     onCancel = { | ||||||
|  |                         for (i in downloadToGroup( | ||||||
|  |                             item, | ||||||
|  |                             downloads | ||||||
|  |                         )) transmissionScreenViewModel.delete(i.id) | ||||||
|  |                     }, | ||||||
|  |                     onDelete = { | ||||||
|  |                         for (i in downloadToGroup( | ||||||
|  |                             item, | ||||||
|  |                             downloads | ||||||
|  |                         )) transmissionScreenViewModel.delete(i.id) | ||||||
|  |                     }, | ||||||
|  |                     onRetry = { | ||||||
|  |                         for (i in downloadToGroup( | ||||||
|  |                             item, | ||||||
|  |                             downloads | ||||||
|  |                         )) transmissionScreenViewModel.retry(i.id) | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |                 HorizontalDivider( | ||||||
|  |                     Modifier.padding(horizontal = 16.dp, vertical = 6.dp), | ||||||
|  |                     2.dp, | ||||||
|  |                     DividerDefaults.color | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fun downloadToGroup( | ||||||
|  |     i: VideoDownloadItemState, | ||||||
|  |     downloads: List<VideoDownloadItemState> | ||||||
|  | ): List<VideoDownloadItemState> { | ||||||
|  |     return downloads.filter { it.vid == i.vid && it.klass == i.klass } | ||||||
|  | } | ||||||
| @@ -0,0 +1,68 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import android.app.Activity | ||||||
|  | import android.content.pm.ActivityInfo | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  |  | ||||||
|  | import androidx.compose.runtime.DisposableEffect | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import com.acitelight.aether.view.components.VideoPlayerLandscape | ||||||
|  | import com.acitelight.aether.view.components.VideoPlayerPortal | ||||||
|  | import kotlin.math.pow | ||||||
|  |  | ||||||
|  | fun formatTime(ms: Long): String { | ||||||
|  |     if (ms <= 0) return "00:00:00" | ||||||
|  |     val totalSeconds = ms / 1000 | ||||||
|  |     val hours = totalSeconds / 3600 | ||||||
|  |     val minutes = (totalSeconds % 3600) / 60 | ||||||
|  |     val seconds = totalSeconds % 60 | ||||||
|  |     return String.format("%02d:%02d:%02d", hours, minutes, seconds) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fun moveBrit(db: Float, activity: Activity, videoPlayerViewModel: VideoPlayerViewModel) { | ||||||
|  |     val attr = activity.window.attributes | ||||||
|  |  | ||||||
|  |     val britUi = (videoPlayerViewModel.brit - db * 0.002f).coerceIn(0f, 1f) | ||||||
|  |     videoPlayerViewModel.brit = britUi | ||||||
|  |  | ||||||
|  |     val gamma = 2.2f | ||||||
|  |     val britSystem = britUi.pow(gamma).coerceIn(0.001f, 1f) | ||||||
|  |  | ||||||
|  |     attr.screenBrightness = britSystem | ||||||
|  |     activity.window.attributes = attr | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VideoPlayer( | ||||||
|  |     videoPlayerViewModel: VideoPlayerViewModel = hiltViewModel<VideoPlayerViewModel>(), | ||||||
|  |     videoId: String, | ||||||
|  |     navController: NavHostController | ||||||
|  | ) { | ||||||
|  |     val context = LocalContext.current | ||||||
|  |     val activity = (context as? Activity)!! | ||||||
|  |  | ||||||
|  |     DisposableEffect(Unit) { | ||||||
|  |         onDispose { | ||||||
|  |             activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     videoPlayerViewModel.init(videoId) | ||||||
|  |  | ||||||
|  |     activity.requestedOrientation = | ||||||
|  |         if(videoPlayerViewModel.isLandscape) | ||||||
|  |             ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE | ||||||
|  |         else | ||||||
|  |             ActivityInfo.SCREEN_ORIENTATION_PORTRAIT | ||||||
|  |  | ||||||
|  |     if (videoPlayerViewModel.startPlaying) { | ||||||
|  |         if (videoPlayerViewModel.isLandscape) { | ||||||
|  |             VideoPlayerLandscape(videoPlayerViewModel) | ||||||
|  |         } else { | ||||||
|  |             VideoPlayerPortal(videoPlayerViewModel, navController) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,306 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import android.widget.Toast | ||||||
|  | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | import androidx.compose.animation.slideInHorizontally | ||||||
|  | import androidx.compose.animation.slideOutHorizontally | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.clickable | ||||||
|  | import androidx.compose.foundation.combinedClickable | ||||||
|  | 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.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.size | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.layout.widthIn | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.items | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.foundation.text.BasicTextField | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.filled.Menu | ||||||
|  | import androidx.compose.material.icons.filled.Search | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.DividerDefaults | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.LocalTextStyle | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.viewModel.VideoScreenViewModel | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.runtime.setValue | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.graphics.Brush | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.style.TextAlign | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.CardPage | ||||||
|  | import com.acitelight.aether.Global.updateRelate | ||||||
|  | import com.acitelight.aether.view.components.VideoCard | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import java.nio.charset.Charset | ||||||
|  | import kotlin.collections.sortedWith | ||||||
|  |  | ||||||
|  | fun videoToView(v: List<Video>): Map<String?, List<Video>> | ||||||
|  | { | ||||||
|  |     return v.map { if(it.video.group != null) it else Video(id=it.id, isLocal = it.isLocal, localBase = it.localBase, | ||||||
|  |         klass = it.klass, video = it.video.copy(group = it.video.name)) }.groupBy { it.video.group } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fun String.toHex(): String { | ||||||
|  |     return this.toByteArray().joinToString("") { "%02x".format(it) } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fun String.hexToString(charset: Charset = Charsets.UTF_8): String { | ||||||
|  |     require(length % 2 == 0) { "Hex string must have even length" } | ||||||
|  |  | ||||||
|  |     val bytes = ByteArray(length / 2) | ||||||
|  |     for (i in bytes.indices) { | ||||||
|  |         val hexByte = substring(i * 2, i * 2 + 2) | ||||||
|  |         bytes[i] = hexByte.toInt(16).toByte() | ||||||
|  |     } | ||||||
|  |     return String(bytes, charset) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VideoScreen( | ||||||
|  |     videoScreenViewModel: VideoScreenViewModel = hiltViewModel<VideoScreenViewModel>(), | ||||||
|  |     navController: NavHostController | ||||||
|  | ) { | ||||||
|  |     val state = rememberLazyStaggeredGridState() | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     val tabIndex by videoScreenViewModel.tabIndex | ||||||
|  |     var menuVisibility by videoScreenViewModel.menuVisibility | ||||||
|  |     var searchFilter by videoScreenViewModel.searchFilter | ||||||
|  |     var doneInit by videoScreenViewModel.doneInit | ||||||
|  |     val vb = videoToView(videoScreenViewModel.videoLibrary.classesMap.getOrDefault( | ||||||
|  |         videoScreenViewModel.videoLibrary.classes.getOrNull( | ||||||
|  |             tabIndex | ||||||
|  |         ), listOf() | ||||||
|  |     ).filter { it.video.name.contains(searchFilter) }).filter { it.key != null } | ||||||
|  |         .map{ i -> Pair(i.key!!, i.value.sortedWith(compareBy(naturalOrder()) { it.video.name }) ) } | ||||||
|  |         .toList() | ||||||
|  |  | ||||||
|  |     if (doneInit) | ||||||
|  |         CardPage(title = "Videos") { | ||||||
|  |             Box(Modifier.fillMaxSize()) | ||||||
|  |             { | ||||||
|  |                 Column( | ||||||
|  |                     modifier = Modifier.fillMaxSize() | ||||||
|  |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         text = "Videos", | ||||||
|  |                         style = MaterialTheme.typography.headlineMedium, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(horizontal =  8.dp) | ||||||
|  |                             .align(Alignment.Start) | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     // TopRow(videoScreenViewModel); | ||||||
|  |                     Row(Modifier.padding(bottom = 4.dp).padding(start = 8.dp)) | ||||||
|  |                     { | ||||||
|  |                         Card( | ||||||
|  |                             shape = RoundedCornerShape(8.dp), | ||||||
|  |                             colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .align(Alignment.CenterVertically) | ||||||
|  |                                 .padding(horizontal = 1.dp) | ||||||
|  |                                 .size(36.dp), | ||||||
|  |                             onClick = { | ||||||
|  |                                 menuVisibility = !menuVisibility | ||||||
|  |                             }) | ||||||
|  |                         { | ||||||
|  |                             Box(Modifier.fillMaxSize()) | ||||||
|  |                             { | ||||||
|  |                                 Icon( | ||||||
|  |                                     modifier = Modifier | ||||||
|  |                                         .size(30.dp) | ||||||
|  |                                         .align(Alignment.Center), | ||||||
|  |                                     imageVector = Icons.Default.Menu, | ||||||
|  |                                     contentDescription = "Catalogue" | ||||||
|  |                                 ) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         Card( | ||||||
|  |                             shape = RoundedCornerShape(8.dp), | ||||||
|  |                             colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .align(Alignment.CenterVertically) | ||||||
|  |                                 .padding(horizontal = 1.dp) | ||||||
|  |                                 .height(36.dp), | ||||||
|  |                             onClick = { | ||||||
|  |                                 menuVisibility = !menuVisibility | ||||||
|  |                             }) | ||||||
|  |                         { | ||||||
|  |                             Box(Modifier.fillMaxHeight()) | ||||||
|  |                             { | ||||||
|  |                                 Text( | ||||||
|  |                                     text = videoScreenViewModel.videoLibrary.classes.getOrNull( | ||||||
|  |                                         tabIndex | ||||||
|  |                                     ) | ||||||
|  |                                         ?: "", | ||||||
|  |                                     style = MaterialTheme.typography.bodyLarge, | ||||||
|  |                                     fontWeight = FontWeight.Bold, | ||||||
|  |                                     modifier = Modifier | ||||||
|  |                                         .align(Alignment.CenterStart) | ||||||
|  |                                         .padding(horizontal = 8.dp), | ||||||
|  |                                     maxLines = 1 | ||||||
|  |                                 ) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         Row( | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .height(36.dp) | ||||||
|  |                                 .widthIn(max = 240.dp) | ||||||
|  |                                 .background(colorScheme.primary, RoundedCornerShape(8.dp)) | ||||||
|  |                                 .padding(horizontal = 6.dp) | ||||||
|  |                         ) { | ||||||
|  |                             Icon( | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .size(30.dp) | ||||||
|  |                                     .align(Alignment.CenterVertically), | ||||||
|  |                                 imageVector = Icons.Default.Search, | ||||||
|  |                                 contentDescription = "Catalogue" | ||||||
|  |                             ) | ||||||
|  |                             Spacer(Modifier.width(4.dp)) | ||||||
|  |                             BasicTextField( | ||||||
|  |                                 value = searchFilter, | ||||||
|  |                                 onValueChange = { searchFilter = it }, | ||||||
|  |                                 textStyle = LocalTextStyle.current.copy( | ||||||
|  |                                     fontSize = 18.sp, | ||||||
|  |                                     color = Color.White, | ||||||
|  |                                     textAlign = TextAlign.Start | ||||||
|  |                                 ), | ||||||
|  |                                 singleLine = true, | ||||||
|  |                                 modifier = Modifier.align(Alignment.CenterVertically) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     HorizontalDivider( | ||||||
|  |                         Modifier.padding(4.dp), | ||||||
|  |                         2.dp, | ||||||
|  |                         DividerDefaults.color | ||||||
|  |                     ) | ||||||
|  |                     LazyVerticalStaggeredGrid( | ||||||
|  |                         columns = StaggeredGridCells.Adaptive(160.dp), | ||||||
|  |                         contentPadding = PaddingValues(8.dp), | ||||||
|  |                         verticalItemSpacing = 8.dp, | ||||||
|  |                         horizontalArrangement = Arrangement.spacedBy( | ||||||
|  |                             8.dp | ||||||
|  |                         ), | ||||||
|  |                         state = state, | ||||||
|  |                         modifier = Modifier.fillMaxSize() | ||||||
|  |                     ) { | ||||||
|  |                         items( | ||||||
|  |                             items = vb, | ||||||
|  |                             key = { "${it.first}/${it.second}" } | ||||||
|  |                         ) { video -> | ||||||
|  |                             Box( | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .fillMaxWidth() | ||||||
|  |                                     .wrapContentHeight() | ||||||
|  |                             ) { | ||||||
|  |                                 if(video.second.isNotEmpty()) | ||||||
|  |                                     VideoCard(video.second, navController, videoScreenViewModel) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 AnimatedVisibility( | ||||||
|  |                     visible = menuVisibility, | ||||||
|  |                     enter = slideInHorizontally(initialOffsetX = { full -> full }), | ||||||
|  |                     exit = slideOutHorizontally(targetOffsetX = { full -> full }), | ||||||
|  |                     modifier = Modifier.align(Alignment.CenterEnd) | ||||||
|  |                 ) { | ||||||
|  |                     Card( | ||||||
|  |                         Modifier | ||||||
|  |                             .fillMaxHeight() | ||||||
|  |                             .width(250.dp) | ||||||
|  |                             .align(Alignment.CenterEnd), | ||||||
|  |                         shape = RoundedCornerShape(8.dp), | ||||||
|  |                         colors = CardDefaults.cardColors(containerColor = colorScheme.surface) | ||||||
|  |                     ) | ||||||
|  |                     { | ||||||
|  |                         LazyColumn { | ||||||
|  |                             items(videoScreenViewModel.videoLibrary.classes) { item -> | ||||||
|  |                                 CatalogueItemRow( | ||||||
|  |                                     item = Pair( | ||||||
|  |                                         videoScreenViewModel.videoLibrary.classes.indexOf(item), | ||||||
|  |                                         item | ||||||
|  |                                     ), | ||||||
|  |                                     onItemClick = { | ||||||
|  |                                         menuVisibility = false | ||||||
|  |                                         videoScreenViewModel.setTabIndex( | ||||||
|  |                                             videoScreenViewModel.videoLibrary.classes.indexOf( | ||||||
|  |                                                 item | ||||||
|  |                                             ) | ||||||
|  |                                         ) | ||||||
|  |                                     } | ||||||
|  |                                 ) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun CatalogueItemRow( | ||||||
|  |     item: Pair<Int, String>, | ||||||
|  |     onItemClick: (Pair<Int, String>) -> Unit | ||||||
|  | ) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     Card( | ||||||
|  |         modifier = Modifier | ||||||
|  |             .clickable { onItemClick(item) } | ||||||
|  |             .padding(4.dp) | ||||||
|  |             .padding(horizontal = 4.dp) | ||||||
|  |             .heightIn(min = 28.dp) | ||||||
|  |             .width(250.dp), | ||||||
|  |         shape = RoundedCornerShape(8.dp), | ||||||
|  |         colors = CardDefaults.cardColors(containerColor = colorScheme.primary) | ||||||
|  |     ) { | ||||||
|  |         Text( | ||||||
|  |             text = item.second, | ||||||
|  |             fontSize = 14.sp, | ||||||
|  |             maxLines = 1, | ||||||
|  |             textAlign = TextAlign.Center, | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |                 .padding(horizontal = 8.dp, vertical = 4.dp) | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,77 @@ | |||||||
|  | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.runtime.mutableStateOf | ||||||
|  | import androidx.compose.runtime.remember | ||||||
|  | import androidx.compose.runtime.setValue | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.lifecycle.ViewModel | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
|  | import com.acitelight.aether.model.BookMark | ||||||
|  | import com.acitelight.aether.model.Comic | ||||||
|  | import com.acitelight.aether.model.ComicRecord | ||||||
|  | import com.acitelight.aether.model.ComicRecordDatabase | ||||||
|  | import com.acitelight.aether.service.ApiClient | ||||||
|  | import com.acitelight.aether.service.MediaManager | ||||||
|  | import com.acitelight.aether.service.RecentManager | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
|  | @HiltViewModel | ||||||
|  | class ComicGridViewModel @Inject constructor( | ||||||
|  |     @ApplicationContext val context: Context, | ||||||
|  |     val mediaManager: MediaManager, | ||||||
|  |     val recentManager: RecentManager, | ||||||
|  |     val apiClient: ApiClient | ||||||
|  | )  : ViewModel() | ||||||
|  | { | ||||||
|  |     var coverHeight by mutableStateOf(220.dp) | ||||||
|  |     var maxHeight = 0.dp | ||||||
|  |  | ||||||
|  |     var imageLoader: ImageLoader? = null | ||||||
|  |     var comic = mutableStateOf<Comic?>(null) | ||||||
|  |     val chapterList = mutableStateListOf<BookMark>() | ||||||
|  |     var db: ComicRecordDatabase? = null | ||||||
|  |     var record = mutableStateOf<ComicRecord?>(null) | ||||||
|  |  | ||||||
|  |     init { | ||||||
|  |         imageLoader =  ImageLoader.Builder(context) | ||||||
|  |             .components { | ||||||
|  |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|  |             } | ||||||
|  |             .build() | ||||||
|  |         db = try{ | ||||||
|  |                 ComicRecordDatabase.getDatabase(context) | ||||||
|  |             }catch (e: Exception) { | ||||||
|  |                 print(e.message) | ||||||
|  |             } as ComicRecordDatabase? | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun resolve(id: String) | ||||||
|  |     { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             if(comic.value == null) { | ||||||
|  |                 comic.value = mediaManager.queryComicInfoSingle(id) | ||||||
|  |                 recentManager.pushComic(context, id) | ||||||
|  |                 val c = comic.value!! | ||||||
|  |                 for (i in c.comic.bookmarks) { | ||||||
|  |                     chapterList.add(i) | ||||||
|  |                 } | ||||||
|  |             }else comic.value = mediaManager.queryComicInfoSingle(id) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun updateProcess(id: String, callback: () -> Unit) | ||||||
|  |     { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             record.value = db?.userDao()?.getById(id.toInt()) | ||||||
|  |             callback() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,78 @@ | |||||||
|  | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.compose.foundation.lazy.LazyListState | ||||||
|  | import androidx.compose.foundation.lazy.rememberLazyListState | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.LaunchedEffect | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.runtime.mutableStateOf | ||||||
|  | import androidx.compose.runtime.remember | ||||||
|  | import androidx.compose.runtime.rememberCoroutineScope | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.lifecycle.ViewModel | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
|  | import com.acitelight.aether.model.Comic | ||||||
|  | import com.acitelight.aether.model.ComicRecord | ||||||
|  | import com.acitelight.aether.model.ComicRecordDatabase | ||||||
|  | import com.acitelight.aether.service.ApiClient | ||||||
|  | import com.acitelight.aether.service.MediaManager | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.coroutines.CoroutineScope | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
|  | @HiltViewModel | ||||||
|  | class ComicPageViewModel @Inject constructor( | ||||||
|  |     val mediaManager: MediaManager, | ||||||
|  |     @ApplicationContext private val context: Context, | ||||||
|  |     val apiClient: ApiClient | ||||||
|  | ) : ViewModel() | ||||||
|  | { | ||||||
|  |     var imageLoader: ImageLoader? = null | ||||||
|  |     var comic = mutableStateOf<Comic?>(null) | ||||||
|  |     var pageList = mutableStateListOf<String>() | ||||||
|  |     var title = mutableStateOf<String>("") | ||||||
|  |     var listState: LazyListState? = null | ||||||
|  |     var showPlane = mutableStateOf(true) | ||||||
|  |     var db: ComicRecordDatabase | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     init{ | ||||||
|  |         imageLoader =  ImageLoader.Builder(context) | ||||||
|  |             .components { | ||||||
|  |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|  |             } | ||||||
|  |             .build() | ||||||
|  |         listState = LazyListState(0, 0) | ||||||
|  |         db = ComicRecordDatabase.getDatabase(context) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Composable | ||||||
|  |     fun Resolve(id: String, page: Int) | ||||||
|  |     { | ||||||
|  |         if(comic.value != null) return | ||||||
|  |         LaunchedEffect(id, page) { | ||||||
|  |             viewModelScope.launch { | ||||||
|  |                 comic.value = mediaManager.queryComicInfoSingle(id) | ||||||
|  |                 comic.value?.let { | ||||||
|  |                     pageList.addAll(it.comic.list) | ||||||
|  |                     title.value = it.comic.comic_name | ||||||
|  |                     listState?.scrollToItem(index = page) | ||||||
|  |                     updateProcess(page) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun updateProcess(page: Int) | ||||||
|  |     { | ||||||
|  |         if(comic.value == null) return | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             db.userDao().insert(ComicRecord(id = comic.value!!.id.toInt(), name = comic.value!!.comic.comic_name, position = page)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,24 +1,71 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
| import com.acitelight.aether.model.Comic | import com.acitelight.aether.model.Comic | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import kotlinx.coroutines.flow.MutableStateFlow | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
| import kotlinx.coroutines.flow.StateFlow | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
| class ComicScreenViewModel : ViewModel() | @HiltViewModel | ||||||
| { | class ComicScreenViewModel @Inject constructor( | ||||||
|     private val _comics = MutableStateFlow<List<Comic>>(emptyList()) |     @ApplicationContext private val context: Context, | ||||||
|     val comics: StateFlow<List<Comic>> = _comics |     val mediaManager: MediaManager, | ||||||
|  |     val apiClient: ApiClient | ||||||
|  | ) : ViewModel() { | ||||||
|  |  | ||||||
|     init |     var imageLoader: ImageLoader? = null; | ||||||
|     { |  | ||||||
|       //  viewModelScope.launch { |     val searchFilter = mutableStateOf("") | ||||||
|       //      val l = MediaManager.listComics() |     val comics = mutableStateListOf<Comic>() | ||||||
|        //     _comics.value = l.map { MediaManager.queryComicInfo(it) } |     val excluded = mutableStateListOf<String>() | ||||||
|        // } |     val included = mutableStateListOf<String>() | ||||||
|  |     val tags = mutableStateListOf<String>() | ||||||
|  |     private val counter = mutableMapOf<String, Int>() | ||||||
|  |  | ||||||
|  |     fun insertItem(newItem: String) { | ||||||
|  |         val newCount = (counter[newItem] ?: 0) + 1 | ||||||
|  |         counter[newItem] = newCount | ||||||
|  |  | ||||||
|  |         if (newItem !in tags) { | ||||||
|  |             val insertIndex = tags.indexOfFirst { counter[it]!! < newCount } | ||||||
|  |                 .takeIf { it >= 0 } ?: tags.size | ||||||
|  |             tags.add(insertIndex, newItem) | ||||||
|  |         } else { | ||||||
|  |             var currentIndex = tags.indexOf(newItem) | ||||||
|  |             while (currentIndex > 0 && counter[tags[currentIndex - 1]]!! < newCount) { | ||||||
|  |                 tags[currentIndex] = tags[currentIndex - 1] | ||||||
|  |                 tags[currentIndex - 1] = newItem | ||||||
|  |                 currentIndex-- | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     init { | ||||||
|  |         imageLoader =  ImageLoader.Builder(context) | ||||||
|  |             .components { | ||||||
|  |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|  |             } | ||||||
|  |             .build() | ||||||
|  |  | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             val l = mediaManager.listComics() | ||||||
|  |             val m = mediaManager.queryComicInfoBulk(l) | ||||||
|  |  | ||||||
|  |             if(m != null) { | ||||||
|  |                 comics.addAll(m.sortedBy { it.id.toInt() }.reversed()) | ||||||
|  |                 tags.addAll(m.flatMap { it.comic.tags }.groupingBy { it }.eachCount() | ||||||
|  |                     .entries.sortedByDescending { it.value } | ||||||
|  |                     .map { it.key }) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,105 +1,38 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
| import android.app.Application | import android.content.Context | ||||||
| import androidx.compose.runtime.Composable |  | ||||||
| import androidx.compose.runtime.mutableStateOf |  | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.compose.runtime.State |  | ||||||
| import androidx.compose.runtime.mutableIntStateOf |  | ||||||
| import androidx.compose.runtime.remember |  | ||||||
| import androidx.compose.ui.platform.LocalContext |  | ||||||
| import androidx.datastore.preferences.core.stringPreferencesKey |  | ||||||
| import androidx.lifecycle.AndroidViewModel |  | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import coil3.ImageLoader | import coil3.ImageLoader | ||||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
| import okhttp3.OkHttpClient |  | ||||||
| import com.acitelight.aether.Global |  | ||||||
| import com.acitelight.aether.dataStore |  | ||||||
| import com.acitelight.aether.model.Video |  | ||||||
| import com.acitelight.aether.model.VideoQueryIndex |  | ||||||
| import com.acitelight.aether.service.ApiClient | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.ApiClient.createOkHttp |  | ||||||
| import com.acitelight.aether.service.AuthManager |  | ||||||
| import com.acitelight.aether.service.MediaManager |  | ||||||
| import com.acitelight.aether.service.MediaManager.token |  | ||||||
| import com.acitelight.aether.service.RecentManager | import com.acitelight.aether.service.RecentManager | ||||||
| import kotlinx.coroutines.flow.Flow | import com.acitelight.aether.service.VideoLibrary | ||||||
| import kotlinx.coroutines.flow.MutableStateFlow |  | ||||||
| import kotlinx.coroutines.flow.StateFlow |  | ||||||
| import kotlinx.coroutines.flow.first |  | ||||||
| import kotlinx.coroutines.flow.map |  | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
|  | import javax.inject.Inject | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  |  | ||||||
| class HomeScreenViewModel(application: Application) : AndroidViewModel(application) |  | ||||||
|  | @HiltViewModel | ||||||
|  | class HomeScreenViewModel @Inject constructor( | ||||||
|  |     val recentManager: RecentManager, | ||||||
|  |     @ApplicationContext val context: Context, | ||||||
|  |     val videoLibrary: VideoLibrary, | ||||||
|  |     val apiClient: ApiClient | ||||||
|  | ) : ViewModel() | ||||||
| { | { | ||||||
|     private val dataStore = application.dataStore |     var imageLoader: ImageLoader? = null | ||||||
|     private val USER_NAME_KEY = stringPreferencesKey("user_name") |  | ||||||
|     private val PRIVATE_KEY   = stringPreferencesKey("private_key") |  | ||||||
|     private val URL_KEY = stringPreferencesKey("url") |  | ||||||
|     private val CERT_KEY   = stringPreferencesKey("cert") |  | ||||||
|  |  | ||||||
|     val userNameFlow: Flow<String> = dataStore.data.map { preferences -> |     init{ | ||||||
|         preferences[USER_NAME_KEY] ?: "" |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     val privateKeyFlow: Flow<String> = dataStore.data.map {  preferences -> |  | ||||||
|         preferences[PRIVATE_KEY] ?: "" |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     val urlFlow: Flow<String> = dataStore.data.map { preferences -> |  | ||||||
|         preferences[URL_KEY] ?: "" |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     val certFlow: Flow<String> = dataStore.data.map {  preferences -> |  | ||||||
|         preferences[CERT_KEY] ?: "" |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     var _init = false |  | ||||||
|     var imageLoader: ImageLoader? = null; |  | ||||||
|  |  | ||||||
|     @Composable |  | ||||||
|     fun Init(){ |  | ||||||
|         if(_init) return |  | ||||||
|         _init = true |  | ||||||
|  |  | ||||||
|         val context = LocalContext.current |  | ||||||
|         imageLoader =  ImageLoader.Builder(context) |         imageLoader =  ImageLoader.Builder(context) | ||||||
|             .components { |             .components { | ||||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|             } |             } | ||||||
|             .build() |             .build() | ||||||
|         remember { |  | ||||||
|             viewModelScope.launch { |  | ||||||
|                 RecentManager.Query(context) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     init { |  | ||||||
|         viewModelScope.launch { |         viewModelScope.launch { | ||||||
|             val u = userNameFlow.first() |             recentManager.queryVideo(context) | ||||||
|             val p = privateKeyFlow.first() |             recentManager.queryComic(context) | ||||||
|             val ur = urlFlow.first() |  | ||||||
|             val c = certFlow.first() |  | ||||||
|  |  | ||||||
|             if(u=="" || p=="" || ur=="" || c=="") return@launch |  | ||||||
|  |  | ||||||
|             try{ |  | ||||||
|                 ApiClient.apply(ur, c) |  | ||||||
|  |  | ||||||
|                 if (MediaManager.token == "null") |  | ||||||
|                     MediaManager.token = AuthManager.fetchToken( |  | ||||||
|                         u, |  | ||||||
|                         p |  | ||||||
|                     )!! |  | ||||||
|  |  | ||||||
|                 Global.loggedIn = true |  | ||||||
|             }catch(e: Exception) |  | ||||||
|             { |  | ||||||
|                 Global.loggedIn = false |  | ||||||
|                 print(e.message) |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -0,0 +1,14 @@ | |||||||
|  | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
|  | import androidx.lifecycle.ViewModel | ||||||
|  | import com.acitelight.aether.service.ApiClient | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @HiltViewModel | ||||||
|  | class LiveScreenViewModel @Inject constructor( | ||||||
|  |     val apiClient: ApiClient | ||||||
|  | ) : ViewModel(){ | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -1,122 +1,145 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
| import android.app.Application |  | ||||||
| import android.content.Context | import android.content.Context | ||||||
| import android.widget.Toast | import android.widget.Toast | ||||||
| import androidx.compose.runtime.mutableStateOf | import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.core.net.toUri | ||||||
| import androidx.datastore.preferences.core.edit | import androidx.lifecycle.ViewModel | ||||||
| import androidx.datastore.preferences.core.stringPreferencesKey |  | ||||||
| import androidx.lifecycle.AndroidViewModel |  | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
|  | import com.acitelight.aether.AetherApp | ||||||
| import com.acitelight.aether.Global | import com.acitelight.aether.Global | ||||||
| import com.acitelight.aether.dataStore |  | ||||||
| import com.acitelight.aether.model.Video |  | ||||||
| import com.acitelight.aether.service.ApiClient | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.AuthManager | import com.acitelight.aether.service.AuthManager | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import kotlinx.coroutines.flow.Flow | import com.acitelight.aether.service.SettingsDataStoreManager | ||||||
| import kotlinx.coroutines.flow.MutableStateFlow | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.flow.first | import kotlinx.coroutines.flow.first | ||||||
| import kotlinx.coroutines.flow.map |  | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  | import java.net.DatagramPacket | ||||||
|  | import java.net.DatagramSocket | ||||||
|  | import java.net.InetAddress | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
| class MeScreenViewModel(application: Application) : AndroidViewModel(application) { | @HiltViewModel | ||||||
|     private val dataStore = application.dataStore | class MeScreenViewModel @Inject constructor( | ||||||
|     private val USER_NAME_KEY = stringPreferencesKey("user_name") |     private val settingsDataStoreManager: SettingsDataStoreManager, | ||||||
|     private val PRIVATE_KEY   = stringPreferencesKey("private_key") |     @ApplicationContext private val context: Context, | ||||||
|     private val URL_KEY = stringPreferencesKey("url") |     val mediaManager: MediaManager, | ||||||
|     private val CERT_KEY   = stringPreferencesKey("cert") |     private val apiClient: ApiClient, | ||||||
|  |     private val authManager: AuthManager | ||||||
|  | ) : ViewModel() { | ||||||
|  |  | ||||||
|     val userNameFlow: Flow<String> = dataStore.data.map { preferences -> |     val username = mutableStateOf("") | ||||||
|         preferences[USER_NAME_KEY] ?: "" |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     val privateKeyFlow: Flow<String> = dataStore.data.map {  preferences -> |  | ||||||
|         preferences[PRIVATE_KEY] ?: "" |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     val urlFlow: Flow<String> = dataStore.data.map { preferences -> |  | ||||||
|         preferences[URL_KEY] ?: "" |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     val certFlow: Flow<String> = dataStore.data.map {  preferences -> |  | ||||||
|         preferences[CERT_KEY] ?: "" |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     val username = mutableStateOf(""); |  | ||||||
|     val privateKey = mutableStateOf("") |     val privateKey = mutableStateOf("") | ||||||
|     val url = mutableStateOf(""); |     val url = mutableStateOf("") | ||||||
|     val cert = mutableStateOf("") |     val cert = mutableStateOf("") | ||||||
|  |     val pak = mutableStateOf("") | ||||||
|  |  | ||||||
|  |     val uss = settingsDataStoreManager.useSelfSignedFlow | ||||||
|  |  | ||||||
|     init { |     init { | ||||||
|         viewModelScope.launch { |         viewModelScope.launch { | ||||||
|             username.value = userNameFlow.first() |             username.value = settingsDataStoreManager.userNameFlow.first() | ||||||
|             privateKey.value = if (privateKeyFlow.first() == "") "" else "******" |             privateKey.value = if (settingsDataStoreManager.privateKeyFlow.first() == "") "" else "******" | ||||||
|             url.value = urlFlow.first() |             url.value = settingsDataStoreManager.urlFlow.first() | ||||||
|             cert.value = certFlow.first() |             cert.value = settingsDataStoreManager.certFlow.first() | ||||||
|  |  | ||||||
|  |             if(username.value=="" || privateKey.value=="" || url.value=="") return@launch | ||||||
|  |  | ||||||
|  |             try{ | ||||||
|  |                 apiClient.apply(context, url.value, if(uss.first()) cert.value else "") | ||||||
|  |  | ||||||
|  |                 authManager.fetchToken( | ||||||
|  |                     username.value, | ||||||
|  |                     settingsDataStoreManager.privateKeyFlow.first() | ||||||
|  |                 )!! | ||||||
|  |  | ||||||
|  |                 Global.loggedIn = true | ||||||
|  |                 withContext(Dispatchers.IO) | ||||||
|  |                 { | ||||||
|  |                     (context as AetherApp).abyssService?.proxy?.config(apiClient.getBase().toUri().host!!, 4096) | ||||||
|  |                     context.abyssService?.downloader?.init() | ||||||
|  |                 } | ||||||
|  |             }catch(e: Exception) | ||||||
|  |             { | ||||||
|  |                 Global.loggedIn = false | ||||||
|  |                 withContext(Dispatchers.IO) | ||||||
|  |                 { | ||||||
|  |                     (context as AetherApp).abyssService?.downloader?.init() | ||||||
|  |                 } | ||||||
|  |                 Toast.makeText(context, "Error: ${e.message}", Toast.LENGTH_SHORT).show() | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun updateServer(u: String, c: String, context: Context) |     fun onUseSelfSignedCheckedChange(isChecked: Boolean) { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             settingsDataStoreManager.saveUseSelfSigned(isChecked) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun updateServer(u: String, c: String) | ||||||
|     { |     { | ||||||
|         viewModelScope.launch { |         viewModelScope.launch { | ||||||
|             dataStore.edit { preferences -> |             settingsDataStoreManager.saveUrl(u) | ||||||
|                 preferences[URL_KEY] = u |             settingsDataStoreManager.saveCert(c) | ||||||
|                 preferences[CERT_KEY] = c |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             Global.loggedIn = false |             Global.loggedIn = false | ||||||
|  |  | ||||||
|             val us = userNameFlow.first() |             val us = settingsDataStoreManager.userNameFlow.first() | ||||||
|             val u = urlFlow.first() |             val p = settingsDataStoreManager.privateKeyFlow.first() | ||||||
|             val c = certFlow.first() |  | ||||||
|             val p = privateKeyFlow.first() |  | ||||||
|  |  | ||||||
|             if (u == "" || c == "" || p == "" || us == "") return@launch |             if (u == "" || p == "" || us == "") return@launch | ||||||
|  |  | ||||||
|             try { |             try { | ||||||
|                 ApiClient.apply(u, c) |                 val usedUrl = apiClient.apply(context, u, if(uss.first()) c else "") | ||||||
|                 MediaManager.token = AuthManager.fetchToken( |                 (context as AetherApp).abyssService?.proxy?.config(apiClient.getBase().toUri().host!!, 4096) | ||||||
|  |                 context.abyssService?.downloader?.init() | ||||||
|  |                 authManager.fetchToken( | ||||||
|                     us, |                     us, | ||||||
|                     p |                     p | ||||||
|                 )!! |                 )!! | ||||||
|  |  | ||||||
|                 Global.loggedIn = true |                 Global.loggedIn = true | ||||||
|                 Toast.makeText(context, "Server Updated", Toast.LENGTH_SHORT).show() |                 Toast.makeText(context, "Server Updated, Used Url: $usedUrl", Toast.LENGTH_SHORT).show() | ||||||
|             } catch (e: Exception) { |             } catch (e: Exception) { | ||||||
|                 print(e.message) |                 print(e.message) | ||||||
|                 Toast.makeText(context, "Invalid Account or Server Information", Toast.LENGTH_SHORT).show() |                 Toast.makeText(context, "${e.message}", Toast.LENGTH_SHORT).show() | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun updateAccount(u: String, p: String, context: Context) { |     fun updateAccount(u: String, p: String) | ||||||
|  |     { | ||||||
|         viewModelScope.launch { |         viewModelScope.launch { | ||||||
|             dataStore.edit { preferences -> |             settingsDataStoreManager.saveUserName(u) | ||||||
|                 preferences[USER_NAME_KEY] = u |             settingsDataStoreManager.savePrivateKey(p) | ||||||
|                 preferences[PRIVATE_KEY] = p |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             privateKey.value = "******" |             privateKey.value = "******" | ||||||
|  |  | ||||||
|             Global.loggedIn = false |             Global.loggedIn = false | ||||||
|  |  | ||||||
|             val u = userNameFlow.first() |             val u = settingsDataStoreManager.userNameFlow.first() | ||||||
|             val p = privateKeyFlow.first() |             val p = settingsDataStoreManager.privateKeyFlow.first() | ||||||
|  |             val ur = settingsDataStoreManager.urlFlow.first() | ||||||
|  |  | ||||||
|             val ur = urlFlow.first() |             if (u == "" || p == "" || ur == "") return@launch | ||||||
|             val c = certFlow.first() |  | ||||||
|  |  | ||||||
|             if (u == "" || p == "" || ur == "" || c == "") return@launch |  | ||||||
|  |  | ||||||
|             try { |             try { | ||||||
|                 MediaManager.token = AuthManager.fetchToken( |                 authManager.fetchToken( | ||||||
|                     u, |                     u, | ||||||
|                     p |                     p | ||||||
|                 )!! |                 )!! | ||||||
|  |  | ||||||
|                 Global.loggedIn = true |                 Global.loggedIn = true | ||||||
|  |                 withContext(Dispatchers.IO) | ||||||
|  |                 { | ||||||
|  |                     (context as AetherApp).abyssService?.proxy?.config(apiClient.getBase().toUri().host!!, 4096) | ||||||
|  |                     context.abyssService?.downloader?.init() | ||||||
|  |                 } | ||||||
|                 Toast.makeText(context, "Account Updated", Toast.LENGTH_SHORT).show() |                 Toast.makeText(context, "Account Updated", Toast.LENGTH_SHORT).show() | ||||||
|             } catch (e: Exception) { |             } catch (e: Exception) { | ||||||
|                 print(e.message) |                 print(e.message) | ||||||
| @@ -124,4 +147,22 @@ class MeScreenViewModel(application: Application) : AndroidViewModel(application | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun sendPacket(p: String) | ||||||
|  |     { | ||||||
|  |         val b = (p + "\r\n").toByteArray(Charsets.UTF_8) | ||||||
|  |  | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             withContext(Dispatchers.IO) { | ||||||
|  |                 val addr = InetAddress.getByName(apiClient.getDomain()) | ||||||
|  |  | ||||||
|  |                 val socket = DatagramSocket() | ||||||
|  |                 val packet = DatagramPacket( | ||||||
|  |                     b, b.size, addr, 4096 | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 socket.send(packet) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -0,0 +1,309 @@ | |||||||
|  | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.runtime.snapshots.SnapshotStateList | ||||||
|  | import androidx.lifecycle.ViewModel | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
|  | import com.acitelight.aether.Global.updateRelate | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.model.VideoDownloadItemState | ||||||
|  | import com.acitelight.aether.service.ApiClient | ||||||
|  | import com.acitelight.aether.service.FetchManager | ||||||
|  | import com.acitelight.aether.service.MediaManager | ||||||
|  | import com.acitelight.aether.service.VideoLibrary | ||||||
|  | import com.acitelight.aether.view.pages.toHex | ||||||
|  | import com.tonyodev.fetch2.Download | ||||||
|  | import com.tonyodev.fetch2.FetchListener | ||||||
|  | import com.tonyodev.fetch2.Status | ||||||
|  | import com.tonyodev.fetch2core.DownloadBlock | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import java.io.File | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
|  | @HiltViewModel | ||||||
|  | class TransmissionScreenViewModel @Inject constructor( | ||||||
|  |     val fetchManager: FetchManager, | ||||||
|  |     @ApplicationContext val context: Context, | ||||||
|  |     val videoLibrary: VideoLibrary, | ||||||
|  |     val mediaManager: MediaManager, | ||||||
|  |     val apiClient: ApiClient | ||||||
|  | ) : ViewModel() { | ||||||
|  |     var imageLoader: ImageLoader? = null | ||||||
|  |     val downloads: SnapshotStateList<VideoDownloadItemState> = mutableStateListOf() | ||||||
|  |  | ||||||
|  |     // map id -> state object reference (no index bookkeeping) | ||||||
|  |     private val idToState: MutableMap<Int, VideoDownloadItemState> = mutableMapOf() | ||||||
|  |  | ||||||
|  |     fun modelToVideo(model: VideoDownloadItemState): Video? { | ||||||
|  |         val fv = videoLibrary.classesMap.map { it.value }.flatten() | ||||||
|  |         return fv.firstOrNull { it.klass == model.klass && it.id == model.vid } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private val fetchListener = object : FetchListener { | ||||||
|  |         override fun onAdded(download: Download) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onQueued(download: Download, waitingOnNetwork: Boolean) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onWaitingNetwork(download: Download) { | ||||||
|  |  | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onProgress( | ||||||
|  |             download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long | ||||||
|  |         ) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onPaused(download: Download) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onResumed(download: Download) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onCompleted(download: Download) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |  | ||||||
|  |             if (download.extras.getString("type", "") == "main") { | ||||||
|  |                 val ii = videoLibrary.classesMap[download.extras.getString( | ||||||
|  |                     "class", | ||||||
|  |                     "" | ||||||
|  |                 )]?.indexOfFirst { it.id == download.extras.getString("id", "") } | ||||||
|  |  | ||||||
|  |                 if (ii != null) { | ||||||
|  |                     val newi = | ||||||
|  |                         videoLibrary.classesMap[download.extras.getString("class", "")]?.get(ii) | ||||||
|  |                     if (newi != null) videoLibrary.classesMap[download.extras.getString( | ||||||
|  |                         "class", | ||||||
|  |                         "" | ||||||
|  |                     )]?.set( | ||||||
|  |                         ii, newi.toLocal(context.getExternalFilesDir(null)!!.path) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onCancelled(download: Download) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onRemoved(download: Download) { | ||||||
|  |             handleRemove(download.id) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onDeleted(download: Download) { | ||||||
|  |             handleRemove(download.id) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onDownloadBlockUpdated( | ||||||
|  |             download: Download, downloadBlock: DownloadBlock, totalBlocks: Int | ||||||
|  |         ) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onStarted( | ||||||
|  |             download: Download, downloadBlocks: List<DownloadBlock>, totalBlocks: Int | ||||||
|  |         ) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onError( | ||||||
|  |             download: Download, error: com.tonyodev.fetch2.Error, throwable: Throwable? | ||||||
|  |         ) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun handleUpsert(download: Download) { | ||||||
|  |         viewModelScope.launch(Dispatchers.Main) { | ||||||
|  |             upsertOnMain(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val state = downloadToState(download) | ||||||
|  |  | ||||||
|  |         if (!videoLibrary.classes.contains(state.klass)) videoLibrary.classes.add(state.klass) | ||||||
|  |  | ||||||
|  |         if (!videoLibrary.classesMap.containsKey(state.klass)) videoLibrary.classesMap[state.klass] = | ||||||
|  |             mutableStateListOf() | ||||||
|  |  | ||||||
|  |         if (videoLibrary.classesMap[state.klass]?.any { it.id == state.vid } != true) { | ||||||
|  |             viewModelScope.launch(Dispatchers.IO) { | ||||||
|  |                 val v = mediaManager.queryVideo(state.klass, state.vid, state) | ||||||
|  |                 if (v != null) { | ||||||
|  |                     videoLibrary.classesMap[state.klass]?.add(v) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun handleRemove(id: Int) { | ||||||
|  |         viewModelScope.launch(Dispatchers.Main) { | ||||||
|  |             removeOnMain(id) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun upsertOnMain(download: Download) { | ||||||
|  |         val existing = idToState[download.id] | ||||||
|  |         if (existing != null) { | ||||||
|  |             // update fields in-place -> minimal recomposition | ||||||
|  |             existing.filePath = download.file | ||||||
|  |             existing.fileName = download.request.extras.getString("name", "") | ||||||
|  |             existing.url = download.url | ||||||
|  |             existing.progress = download.progress | ||||||
|  |             existing.status = download.status | ||||||
|  |             existing.downloadedBytes = download.downloaded | ||||||
|  |             existing.totalBytes = download.total | ||||||
|  |         } else { | ||||||
|  |             // new item: add to head (or tail depending on preference) | ||||||
|  |             val newState = downloadToState(download) | ||||||
|  |             downloads.add(0, newState) | ||||||
|  |             idToState[newState.id] = newState | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun removeOnMain(id: Int) { | ||||||
|  |         val state = idToState.remove(id) | ||||||
|  |         if (state != null) { | ||||||
|  |             downloads.remove(state) | ||||||
|  |         } else { | ||||||
|  |             val idx = downloads.indexOfFirst { it.id == id } | ||||||
|  |             if (idx >= 0) { | ||||||
|  |                 val removed = downloads.removeAt(idx) | ||||||
|  |                 idToState.remove(removed.id) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun downloadToState(download: Download): VideoDownloadItemState { | ||||||
|  |         val filePath = download.file | ||||||
|  |  | ||||||
|  |         return VideoDownloadItemState( | ||||||
|  |             id = download.id, | ||||||
|  |             fileName = download.request.extras.getString("name", ""), | ||||||
|  |             filePath = filePath, | ||||||
|  |             url = download.url, | ||||||
|  |             progress = download.progress, | ||||||
|  |             status = download.status, | ||||||
|  |             downloadedBytes = download.downloaded, | ||||||
|  |             totalBytes = download.total, | ||||||
|  |             klass = download.extras.getString("class", ""), | ||||||
|  |             vid = download.extras.getString("id", ""), | ||||||
|  |             type = download.extras.getString("type", "") | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     // UI actions delegated to FetchManager | ||||||
|  |     fun pause(id: Int) = fetchManager.pause(id) | ||||||
|  |     fun resume(id: Int) = fetchManager.resume(id) | ||||||
|  |     fun retry(id: Int) = fetchManager.retry(id) | ||||||
|  |     fun delete(id: Int) { | ||||||
|  |         fetchManager.delete(id) { | ||||||
|  |             viewModelScope.launch(Dispatchers.Main) { removeOnMain(id) } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override fun onCleared() { | ||||||
|  |         super.onCleared() | ||||||
|  |         fetchManager.removeListener() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun playStart(model: VideoDownloadItemState, navigator: NavHostController) | ||||||
|  |     { | ||||||
|  |         val downloaded = fetchManager.getAllDownloadsAsync().filter { | ||||||
|  |             it.status == Status.COMPLETED && it.extras.getString( | ||||||
|  |                 "class", | ||||||
|  |                 "" | ||||||
|  |             ) != "comic" && it.extras.getString( | ||||||
|  |                 "type", | ||||||
|  |                 "" | ||||||
|  |             ) == "main" | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val jsonQuery = downloaded.map { | ||||||
|  |             File( | ||||||
|  |                 context.getExternalFilesDir(null), | ||||||
|  |                 "videos/${ | ||||||
|  |                     it.extras.getString( | ||||||
|  |                         "class", | ||||||
|  |                         "" | ||||||
|  |                     ) | ||||||
|  |                 }/${it.extras.getString("id", "")}/summary.json" | ||||||
|  |             ).readText() | ||||||
|  |         } | ||||||
|  |             .map { | ||||||
|  |                 Json.decodeFromString<Video>(it) | ||||||
|  |                     .toLocal(context.getExternalFilesDir(null)!!.path) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |         updateRelate( | ||||||
|  |             jsonQuery, | ||||||
|  |             jsonQuery.first { it.id == model.vid && it.klass == model.klass } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         val playList = mutableListOf<String>() | ||||||
|  |         val fv = videoLibrary.classesMap.map { it.value }.flatten() | ||||||
|  |         val video = fv.firstOrNull { it.klass == model.klass && it.id == model.vid } | ||||||
|  |  | ||||||
|  |         if (video != null) { | ||||||
|  |             val group = fv.filter { it.klass == video.klass && it.video.group == video.video.group && it.video.group != "null" } | ||||||
|  |             for (i in group.sortedWith(compareBy(naturalOrder()) { it.video.name })) { | ||||||
|  |                 playList.add("${i.klass}/${i.id}") | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val route = "video_player_route/${(playList.joinToString(",") + "|${model.vid}").toHex()}" | ||||||
|  |         withContext(Dispatchers.Main) { | ||||||
|  |             navigator.navigate(route) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     init { | ||||||
|  |         imageLoader = ImageLoader.Builder(context).components { | ||||||
|  |             add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|  |         }.build() | ||||||
|  |  | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             fetchManager.setListener(fetchListener) | ||||||
|  |             val downloaded = fetchManager.getAllDownloadsAsync() | ||||||
|  |  | ||||||
|  |             downloads.clear() | ||||||
|  |             idToState.clear() | ||||||
|  |             downloaded.forEach { d -> | ||||||
|  |                 val s = downloadToState(d) | ||||||
|  |                 downloads.add(s) | ||||||
|  |                 idToState[s.id] = s | ||||||
|  |  | ||||||
|  |                 if (d.extras.getString("type", "") == "main") { | ||||||
|  |                     if (!videoLibrary.classes.contains(s.klass)) | ||||||
|  |                         videoLibrary.classes.add(s.klass) | ||||||
|  |  | ||||||
|  |                     if (!videoLibrary.classesMap.containsKey(s.klass)) videoLibrary.classesMap[s.klass] = | ||||||
|  |                         mutableStateListOf() | ||||||
|  |  | ||||||
|  |                     if (videoLibrary.classesMap[s.klass]?.any { it.id == s.vid } != true) { | ||||||
|  |                         val v = mediaManager.queryVideo(s.klass, s.vid, s) | ||||||
|  |                         if (v != null) { | ||||||
|  |                             videoLibrary.classesMap[s.klass]?.add(v) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,123 +1,360 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import android.net.Uri | ||||||
|  | import android.widget.Toast | ||||||
| import androidx.annotation.OptIn | import androidx.annotation.OptIn | ||||||
| import androidx.compose.runtime.Composable |  | ||||||
| import androidx.compose.runtime.State |  | ||||||
| import androidx.compose.runtime.getValue | import androidx.compose.runtime.getValue | ||||||
| import androidx.compose.runtime.mutableFloatStateOf | import androidx.compose.runtime.mutableFloatStateOf | ||||||
| import androidx.compose.runtime.mutableIntStateOf | import androidx.compose.runtime.mutableIntStateOf | ||||||
|  | import androidx.compose.runtime.mutableLongStateOf | ||||||
| import androidx.compose.runtime.mutableStateOf | import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.compose.runtime.remember |  | ||||||
| import androidx.compose.runtime.setValue | import androidx.compose.runtime.setValue | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.core.net.toUri | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import androidx.media3.common.MediaItem | import androidx.media3.common.MediaItem | ||||||
|  | import androidx.media3.common.PlaybackException | ||||||
| import androidx.media3.common.Player | import androidx.media3.common.Player | ||||||
|  | import androidx.media3.common.Player.STATE_ENDED | ||||||
| import androidx.media3.common.Player.STATE_READY | import androidx.media3.common.Player.STATE_READY | ||||||
|  | import androidx.media3.common.Tracks | ||||||
|  | import androidx.media3.common.text.Cue | ||||||
| import androidx.media3.common.util.UnstableApi | import androidx.media3.common.util.UnstableApi | ||||||
|  | import androidx.media3.datasource.DefaultDataSource | ||||||
| import androidx.media3.datasource.okhttp.OkHttpDataSource | import androidx.media3.datasource.okhttp.OkHttpDataSource | ||||||
| import androidx.media3.exoplayer.ExoPlayer | import androidx.media3.exoplayer.ExoPlayer | ||||||
| import androidx.media3.exoplayer.source.DefaultMediaSourceFactory | import androidx.media3.exoplayer.source.DefaultMediaSourceFactory | ||||||
|  | import androidx.media3.exoplayer.trackselection.DefaultTrackSelector | ||||||
| import coil3.ImageLoader | import coil3.ImageLoader | ||||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
| import com.acitelight.aether.Global | import com.acitelight.aether.model.KeyImage | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import com.acitelight.aether.model.VideoQueryIndex | import com.acitelight.aether.model.VideoQueryIndex | ||||||
| import com.acitelight.aether.service.ApiClient.createOkHttp | import com.acitelight.aether.model.VideoRecord | ||||||
|  | import com.acitelight.aether.model.VideoRecordDatabase | ||||||
|  | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import com.acitelight.aether.service.RecentManager | import com.acitelight.aether.service.RecentManager | ||||||
| import com.acitelight.aether.view.hexToString | import com.acitelight.aether.service.VideoLibrary | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  | import com.acitelight.aether.view.pages.hexToString | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import kotlinx.coroutines.CoroutineScope | import kotlinx.coroutines.CoroutineScope | ||||||
| import kotlinx.coroutines.Dispatchers | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.delay | import kotlinx.coroutines.delay | ||||||
|  | import kotlinx.coroutines.flow.first | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  | import okhttp3.Request | ||||||
|  | import java.io.File | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
| class VideoPlayerViewModel() : ViewModel() | @HiltViewModel | ||||||
| { | class VideoPlayerViewModel @Inject constructor( | ||||||
|  |     @ApplicationContext private val context: Context, | ||||||
|  |     val mediaManager: MediaManager, | ||||||
|  |     val recentManager: RecentManager, | ||||||
|  |     val videoLibrary: VideoLibrary, | ||||||
|  |     val apiClient: ApiClient | ||||||
|  | ) : ViewModel() { | ||||||
|  |     var showPlaylist by mutableStateOf(false) | ||||||
|  |     var isLandscape by mutableStateOf(false) | ||||||
|     var tabIndex by mutableIntStateOf(0) |     var tabIndex by mutableIntStateOf(0) | ||||||
|     var isPlaying by  mutableStateOf(true) |     var isPlaying by mutableStateOf(true) | ||||||
|     var playProcess by mutableFloatStateOf(0.0f) |     var playProcess by mutableFloatStateOf(0.0f) | ||||||
|     var planeVisibility by mutableStateOf(true) |     var planeVisibility by mutableStateOf(true) | ||||||
|     var isLongPressing by mutableStateOf(false) |     var isLongPressing by mutableStateOf(false) | ||||||
|     var dragging by mutableStateOf(false) |  | ||||||
|  |  | ||||||
|     var thumbUp by mutableIntStateOf(0) |     // -1 : Not dragging | ||||||
|     var thumbDown by mutableIntStateOf(0) |     // 0  : Seek | ||||||
|     var star by mutableStateOf(false) |     // 1  : Volume | ||||||
|  |     // 2  : Brightness | ||||||
|     private var _init: Boolean = false; |     var draggingPurpose by mutableIntStateOf(-1) | ||||||
|  |     var locked by mutableStateOf(false) | ||||||
|  |     private var _init: Boolean = false | ||||||
|     var startPlaying by mutableStateOf(false) |     var startPlaying by mutableStateOf(false) | ||||||
|     var renderedFirst = false |     var renderedFirst = false | ||||||
|     var video: Video? = null |     var videos: List<Video> = listOf() | ||||||
|  |  | ||||||
|     val dataSourceFactory = OkHttpDataSource.Factory(createOkHttp()) |     private val httpDataSourceFactory = OkHttpDataSource.Factory(apiClient.getClient()) | ||||||
|     var imageLoader: ImageLoader? = null; |     private val defaultDataSourceFactory by lazy { | ||||||
|  |         DefaultDataSource.Factory( | ||||||
|  |             context, | ||||||
|  |             httpDataSourceFactory | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var imageLoader: ImageLoader? = null | ||||||
|  |     var brit by mutableFloatStateOf(0.0f) | ||||||
|  |     val database: VideoRecordDatabase = VideoRecordDatabase.getDatabase(context) | ||||||
|  |     var cues by mutableStateOf(listOf<Cue>()) | ||||||
|  |     var currentKlass = mutableStateOf("") | ||||||
|  |     var currentId = mutableStateOf("") | ||||||
|  |     var currentName = mutableStateOf("") | ||||||
|  |     var currentDuration = mutableLongStateOf(0) | ||||||
|  |     var currentGallery = mutableStateOf(listOf<KeyImage>()) | ||||||
|  |  | ||||||
|     @OptIn(UnstableApi::class) |     @OptIn(UnstableApi::class) | ||||||
|     @Composable |     fun init(videoId: String) { | ||||||
|     fun Init(videoId: String) |         if (_init) | ||||||
|     { |             return | ||||||
|         if(_init) return; |         _init = true | ||||||
|         val context = LocalContext.current |  | ||||||
|         val v = videoId.hexToString() |  | ||||||
|  |  | ||||||
|         imageLoader =  ImageLoader.Builder(context) |         val oId = videoId.hexToString() | ||||||
|  |         var spec = "-1" | ||||||
|  |         var vs: MutableList<List<String>> | ||||||
|  |  | ||||||
|  |         if(oId.contains("|")) | ||||||
|  |         { | ||||||
|  |             vs = oId.split("|")[0].split(",").map { it.split("/") }.toMutableList() | ||||||
|  |             spec = oId.split("|")[1] | ||||||
|  |         }else{ | ||||||
|  |             vs = oId.split(",").map { it.split("/") }.toMutableList() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         imageLoader = ImageLoader.Builder(context) | ||||||
|             .components { |             .components { | ||||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|             } |             } | ||||||
|             .build() |             .build() | ||||||
|  |  | ||||||
|         remember { |         viewModelScope.launch { | ||||||
|             viewModelScope.launch { |             videos = mediaManager.queryVideoBulk(vs.first()[0], vs.map { it[1] })!! | ||||||
|                 video = MediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!! |  | ||||||
|                 RecentManager.Push(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1])) |  | ||||||
|                 _player = ExoPlayer |  | ||||||
|                     .Builder(context) |  | ||||||
|                     .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) |  | ||||||
|                     .build().apply { |  | ||||||
|                     val url = video?.getVideo() ?: "" |  | ||||||
|                     val mediaItem = MediaItem.fromUri(url) |  | ||||||
|                     setMediaItem(mediaItem) |  | ||||||
|                     prepare() |  | ||||||
|                     playWhenReady = true |  | ||||||
|  |  | ||||||
|                     addListener(object : Player.Listener { |             val ii = database.userDao().getAll().first() | ||||||
|                         override fun onPlaybackStateChanged(playbackState: Int) { |             val ix = ii.filter { it.id in videos.map{ m -> m.id } }.maxByOrNull { it.time } | ||||||
|                             if (playbackState == STATE_READY) { |  | ||||||
|                                 startPlaying = true |             startPlay( | ||||||
|                             } |                 if(spec != "-1") | ||||||
|  |                     videos.first { it.id == spec} | ||||||
|  |                 else if (ix != null) | ||||||
|  |                     videos.first { it.id == ix.id } | ||||||
|  |                 else videos.first() | ||||||
|  |             ) | ||||||
|  |             startListen() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Try to resolve the given subtitle pathOrUrl to a Uri. | ||||||
|  |      * - If it's a local path and file exists -> Uri.fromFile | ||||||
|  |      * - If it's a http(s) URL -> try HEAD; if HEAD unsupported, try GET with Range: bytes=0-1 | ||||||
|  |      * - Return null when unreachable / 404 / not exist | ||||||
|  |      */ | ||||||
|  |     private suspend fun tryResolveSubtitleUri(pathOrUrl: String?): Uri? = | ||||||
|  |         withContext(Dispatchers.IO) { | ||||||
|  |             if (pathOrUrl.isNullOrBlank()) return@withContext null | ||||||
|  |             val trimmed = pathOrUrl.trim() | ||||||
|  |  | ||||||
|  |             // Remote URL case (http/https) | ||||||
|  |             if (trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith( | ||||||
|  |                     "https://", | ||||||
|  |                     ignoreCase = true | ||||||
|  |                 ) | ||||||
|  |             ) { | ||||||
|  |                 try { | ||||||
|  |                     val client = apiClient.getClient() | ||||||
|  |  | ||||||
|  |                     val headReq = Request.Builder().url(trimmed).head().build() | ||||||
|  |                     val headResp = try { | ||||||
|  |                         client.newCall(headReq).execute() | ||||||
|  |                     } catch (_: Exception) { | ||||||
|  |                         null | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     headResp?.use { resp -> | ||||||
|  |                         val code = resp.code | ||||||
|  |                         if (code == 200 || code == 206) { | ||||||
|  |                             return@withContext trimmed.toUri() | ||||||
|  |                         } | ||||||
|  |                         if (code == 404) { | ||||||
|  |                             return@withContext null | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     val rangeReq = Request.Builder() | ||||||
|  |                         .url(trimmed) | ||||||
|  |                         .addHeader("Range", "bytes=0-1") | ||||||
|  |                         .get() | ||||||
|  |                         .build() | ||||||
|  |  | ||||||
|  |                     val rangeResp = try { | ||||||
|  |                         client.newCall(rangeReq).execute() | ||||||
|  |                     } catch (_: Exception) { | ||||||
|  |                         null | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     rangeResp?.use { resp -> | ||||||
|  |                         val code = resp.code | ||||||
|  |                         if (code == 206) { | ||||||
|  |                             return@withContext trimmed.toUri() | ||||||
|                         } |                         } | ||||||
|  |  | ||||||
|                         override fun onRenderedFirstFrame() { |                         if (code == 200) { | ||||||
|                             super.onRenderedFirstFrame() |                             return@withContext trimmed.toUri() | ||||||
|                             renderedFirst = true |  | ||||||
|                         } |                         } | ||||||
|                     }) |  | ||||||
|  |                         if (code == 404) { | ||||||
|  |                             return@withContext null | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } catch (_: Exception) { | ||||||
|  |                     return@withContext null | ||||||
|                 } |                 } | ||||||
|                 startListen() |                 return@withContext null | ||||||
|  |             } else { | ||||||
|  |                 // Local path | ||||||
|  |                 val f = File(trimmed) | ||||||
|  |                 return@withContext if (f.exists() && f.isFile) Uri.fromFile(f) else null | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         _init = true; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     @OptIn(UnstableApi::class) |     @OptIn(UnstableApi::class) | ||||||
|     fun startListen() |     fun startListen() { | ||||||
|     { |  | ||||||
|         CoroutineScope(Dispatchers.Main).launch { |         CoroutineScope(Dispatchers.Main).launch { | ||||||
|             while (_player?.isReleased != true) { |             while (_init) { | ||||||
|                 val __player = _player!!; |                 player?.let { playProcess = it.currentPosition.toFloat() / it.duration.toFloat() } | ||||||
|                 playProcess = __player.currentPosition.toFloat() / __player.duration.toFloat() |  | ||||||
|                 delay(100) |                 delay(100) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     var _player: ExoPlayer? = null; |     @OptIn(UnstableApi::class) | ||||||
|  |     suspend fun startPlay(video: Video) { | ||||||
|  |         if (currentId.value.isNotEmpty() && currentKlass.value.isNotEmpty()) { | ||||||
|  |             val pos = player?.currentPosition ?: 0L | ||||||
|  |             database.userDao().insert( | ||||||
|  |                 VideoRecord( | ||||||
|  |                     currentId.value, | ||||||
|  |                     currentKlass.value, | ||||||
|  |                     pos, | ||||||
|  |                     System.currentTimeMillis(), | ||||||
|  |                     videos.joinToString(",") { it.id }) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         renderedFirst = false | ||||||
|  |         currentId.value = video.id | ||||||
|  |         currentKlass.value = video.klass | ||||||
|  |         currentName.value = video.video.name | ||||||
|  |         currentDuration.longValue = video.video.duration | ||||||
|  |         currentGallery.value = video.getGallery(apiClient) | ||||||
|  |  | ||||||
|  |         player?.apply { | ||||||
|  |             stop() | ||||||
|  |             clearMediaItems() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         recentManager.pushVideo(context, VideoQueryIndex(video.klass, video.id)) | ||||||
|  |  | ||||||
|  |         val subtitleCandidate = video.getSubtitle(apiClient).trim() | ||||||
|  |         val subtitleUri = tryResolveSubtitleUri(subtitleCandidate) | ||||||
|  |  | ||||||
|  |         if (player == null) { | ||||||
|  |             val trackSelector = DefaultTrackSelector(context) | ||||||
|  |             val builder = ExoPlayer.Builder(context) | ||||||
|  |                 .setMediaSourceFactory(DefaultMediaSourceFactory(defaultDataSourceFactory)) | ||||||
|  |  | ||||||
|  |             player = builder.setTrackSelector(trackSelector).build().apply { | ||||||
|  |                 addListener(object : Player.Listener { | ||||||
|  |                     override fun onTracksChanged(tracks: Tracks) { | ||||||
|  |                         val trackSelector = player?.trackSelector | ||||||
|  |                         if (trackSelector is DefaultTrackSelector) { | ||||||
|  |                             val parameters = trackSelector.buildUponParameters() | ||||||
|  |                                 .setSelectUndeterminedTextLanguage(true) | ||||||
|  |                                 .build() | ||||||
|  |                             trackSelector.parameters = parameters | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     override fun onPlaybackStateChanged(playbackState: Int) { | ||||||
|  |                         when(playbackState) | ||||||
|  |                         { | ||||||
|  |                             STATE_READY -> { | ||||||
|  |                                 startPlaying = true | ||||||
|  |                             } | ||||||
|  |                             STATE_ENDED -> { | ||||||
|  |                                 player?.seekTo(0) | ||||||
|  |                                 player?.pause() | ||||||
|  |                             } | ||||||
|  |                             else -> { | ||||||
|  |  | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     override fun onRenderedFirstFrame() { | ||||||
|  |                         if (!renderedFirst) { | ||||||
|  |                             viewModelScope.launch { | ||||||
|  |                                 val ii = database.userDao().get(currentId.value, currentKlass.value) | ||||||
|  |                                 if (ii != null) { | ||||||
|  |                                     player?.seekTo(ii.position) | ||||||
|  |                                     Toast.makeText( | ||||||
|  |                                         context, | ||||||
|  |                                         "Recover from ${formatTime(ii.position)} ", | ||||||
|  |                                         Toast.LENGTH_SHORT | ||||||
|  |                                     ).show() | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                         renderedFirst = true | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     override fun onPlayerError(error: PlaybackException) { | ||||||
|  |                         print(error.message) | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     override fun onCues(lcues: MutableList<Cue>) { | ||||||
|  |                         cues = lcues | ||||||
|  |                     } | ||||||
|  |                 }) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val url = video.getVideo(apiClient) | ||||||
|  |         val videoUri = if (video.isLocal) Uri.fromFile(File(url)) else url.toUri() | ||||||
|  |  | ||||||
|  |         val mediaItem: MediaItem = if (subtitleUri != null) { | ||||||
|  |             val subConfig = MediaItem.SubtitleConfiguration.Builder(subtitleUri) | ||||||
|  |                 .setMimeType("text/vtt") | ||||||
|  |                 .build() | ||||||
|  |  | ||||||
|  |             MediaItem.Builder() | ||||||
|  |                 .setUri(videoUri) | ||||||
|  |                 .setSubtitleConfigurations(listOf(subConfig)) | ||||||
|  |                 .build() | ||||||
|  |         } else { | ||||||
|  |             MediaItem.fromUri(videoUri) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         player?.apply { | ||||||
|  |             setMediaItem(mediaItem) | ||||||
|  |             prepare() | ||||||
|  |             playWhenReady = true | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var player: ExoPlayer? = null | ||||||
|  |  | ||||||
|     override fun onCleared() { |     override fun onCleared() { | ||||||
|         super.onCleared() |         super.onCleared() | ||||||
|         _player?.release() |  | ||||||
|  |         _init = false | ||||||
|  |         val pos = player?.currentPosition ?: 0L | ||||||
|  |         player?.release() | ||||||
|  |         player = null | ||||||
|  |  | ||||||
|  |         CoroutineScope(Dispatchers.IO).launch { | ||||||
|  |             if (currentId.value.isNotEmpty() && currentKlass.value.isNotEmpty()) | ||||||
|  |                 database.userDao().insert( | ||||||
|  |                     VideoRecord( | ||||||
|  |                         currentId.value, | ||||||
|  |                         currentKlass.value, | ||||||
|  |                         pos, | ||||||
|  |                         System.currentTimeMillis(), | ||||||
|  |                         videos.joinToString(",") { it.id }) | ||||||
|  |                 ) | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,77 +1,130 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
| import android.app.Application | import android.content.Context | ||||||
| import androidx.compose.runtime.Composable |  | ||||||
| import androidx.compose.runtime.State | import androidx.compose.runtime.State | ||||||
| import androidx.compose.runtime.mutableIntStateOf | import androidx.compose.runtime.mutableIntStateOf | ||||||
| import androidx.compose.runtime.mutableStateListOf | import androidx.compose.runtime.mutableStateListOf | ||||||
| import androidx.compose.runtime.mutableStateMapOf |  | ||||||
| import androidx.compose.runtime.mutableStateOf | import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.compose.runtime.snapshots.SnapshotStateList | import androidx.lifecycle.ViewModel | ||||||
| import androidx.compose.ui.platform.LocalContext |  | ||||||
| import androidx.datastore.preferences.core.stringPreferencesKey |  | ||||||
| import androidx.lifecycle.AndroidViewModel |  | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import coil3.ImageLoader | import coil3.ImageLoader | ||||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
| import com.acitelight.aether.dataStore | import com.acitelight.aether.Global | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import com.acitelight.aether.service.ApiClient.createOkHttp | import com.acitelight.aether.service.ApiClient | ||||||
|  | import com.acitelight.aether.service.FetchManager | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import kotlinx.coroutines.flow.Flow | import com.acitelight.aether.service.VideoLibrary | ||||||
|  | import com.tonyodev.fetch2.Status | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.flow.filter | ||||||
| import kotlinx.coroutines.flow.first | import kotlinx.coroutines.flow.first | ||||||
| import kotlinx.coroutines.flow.map |  | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import java.io.File | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
| class VideoScreenViewModel(application: Application) : AndroidViewModel(application) |  | ||||||
| { | @HiltViewModel | ||||||
|  | class VideoScreenViewModel @Inject constructor( | ||||||
|  |     val fetchManager: FetchManager, | ||||||
|  |     @ApplicationContext val context: Context, | ||||||
|  |     val mediaManager: MediaManager, | ||||||
|  |     val videoLibrary: VideoLibrary, | ||||||
|  |     val apiClient: ApiClient | ||||||
|  | ) : ViewModel() { | ||||||
|     private val _tabIndex = mutableIntStateOf(0) |     private val _tabIndex = mutableIntStateOf(0) | ||||||
|     val tabIndex: State<Int> = _tabIndex |     val tabIndex: State<Int> = _tabIndex | ||||||
|     // val videos = mutableStateListOf<Video>() |     var imageLoader: ImageLoader? = null | ||||||
|     // private val _klasses = MutableStateFlow<List<String>>(emptyList()) |     var menuVisibility = mutableStateOf(false) | ||||||
|     var classes = mutableStateListOf<String>() |     var searchFilter = mutableStateOf("") | ||||||
|     val classesMap = mutableStateMapOf<String, SnapshotStateList<Video>>() |     var doneInit = mutableStateOf(false) | ||||||
|  |  | ||||||
|     var imageLoader: ImageLoader? = null; |  | ||||||
|  |  | ||||||
|     @Composable |  | ||||||
|     fun SetupClient() |  | ||||||
|     { |  | ||||||
|         val context = LocalContext.current |  | ||||||
|         imageLoader =  ImageLoader.Builder(context) |  | ||||||
|             .components { |  | ||||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) |  | ||||||
|             } |  | ||||||
|             .build() |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     suspend fun init() { |     suspend fun init() { | ||||||
|         classes.addAll(MediaManager.listVideoKlasses()) |         fetchManager.configured.filter { it }.first() | ||||||
|         for(it in classes) |  | ||||||
|         { |         if (Global.loggedIn) { | ||||||
|             classesMap[it] = mutableStateListOf<Video>() |             videoLibrary.classes.addAll( | ||||||
|  |                 mediaManager.listVideoKlasses().filter { it !in videoLibrary.classes } | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             if(videoLibrary.classes.isEmpty()) | ||||||
|  |                 return | ||||||
|  |  | ||||||
|  |             var i = 0 | ||||||
|  |             for (it in videoLibrary.classes) { | ||||||
|  |                 videoLibrary.updatingMap[i++] = false | ||||||
|  |                 if(!videoLibrary.classesMap.containsKey(it)) | ||||||
|  |                     videoLibrary.classesMap[it] = mutableStateListOf() | ||||||
|  |             } | ||||||
|  |             videoLibrary.updatingMap[0] = true | ||||||
|  |             val vl = | ||||||
|  |                 mediaManager.queryVideoBulk(videoLibrary.classes[0], mediaManager.queryVideoKlasses(videoLibrary.classes[0])) | ||||||
|  |  | ||||||
|  |             if (vl != null) { | ||||||
|  |                 val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name }) | ||||||
|  |                 val existsId = videoLibrary.classesMap[videoLibrary.classes[0]]?.map { it.id } | ||||||
|  |  | ||||||
|  |                 videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(r.filter { existsId == null || it.id !in existsId }) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         else { | ||||||
|  |             videoLibrary.classes.add("Offline") | ||||||
|  |             videoLibrary.updatingMap[0] = true | ||||||
|  |             videoLibrary.classesMap["Offline"] = mutableStateListOf() | ||||||
|  |  | ||||||
|  |             val downloaded = fetchManager.getAllDownloadsAsync().filter { | ||||||
|  |                 it.status == Status.COMPLETED && | ||||||
|  |                 it.extras.getString("class", "") != "comic" && | ||||||
|  |                 it.extras.getString("type", "") == "main" | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             val jsonQuery = downloaded.map{ File( | ||||||
|  |                 context.getExternalFilesDir(null), | ||||||
|  |                 "videos/${it.extras.getString("class", "")}/${it.extras.getString("id", "")}/summary.json").readText() } | ||||||
|  |                 .map {  Json.decodeFromString<Video>(it).toLocal(context.getExternalFilesDir(null)!!.path) } | ||||||
|  |  | ||||||
|  |             videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(jsonQuery) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         MediaManager.listVideos(classes[0]){ |         doneInit.value = true | ||||||
|             v -> classesMap[classes[0]]?.add(v) |     } | ||||||
|  |  | ||||||
|  |     fun setTabIndex(index: Int) { | ||||||
|  |         viewModelScope.launch() | ||||||
|  |         { | ||||||
|  |             _tabIndex.intValue = index | ||||||
|  |             if (videoLibrary.updatingMap[index] == true) return@launch | ||||||
|  |  | ||||||
|  |             videoLibrary.updatingMap[index] = true | ||||||
|  |  | ||||||
|  |             val vl = mediaManager.queryVideoBulk( | ||||||
|  |                 videoLibrary.classes[index], | ||||||
|  |                 mediaManager.queryVideoKlasses(videoLibrary.classes[index]) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             if (vl != null) { | ||||||
|  |                 val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name }) | ||||||
|  |                 val existsId = videoLibrary.classesMap[videoLibrary.classes[index]]?.map { it.id } | ||||||
|  |                 videoLibrary.classesMap[videoLibrary.classes[index]]?.addAll(r.filter { existsId == null || it.id !in existsId }) | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun setTabIndex(index: Int) |     suspend fun download(video: Video) { | ||||||
|     { |         fetchManager.startVideoDownload(video) | ||||||
|         viewModelScope.launch() |  | ||||||
|         { |  | ||||||
|             _tabIndex.intValue = index; |  | ||||||
|  |  | ||||||
|             MediaManager.listVideos(classes[index]) |  | ||||||
|             { |  | ||||||
|                 v -> classesMap[classes[index]]?.add(v) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     init { |     init { | ||||||
|         viewModelScope.launch { |         imageLoader = ImageLoader.Builder(context) | ||||||
|  |             .components { | ||||||
|  |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|  |             } | ||||||
|  |             .build() | ||||||
|  |  | ||||||
|  |         viewModelScope.launch(Dispatchers.IO) { | ||||||
|             init() |             init() | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										74
									
								
								app/src/main/res/drawable/aether_background.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,74 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <vector | ||||||
|  |     android:height="108dp" | ||||||
|  |     android:width="108dp" | ||||||
|  |     android:viewportHeight="108" | ||||||
|  |     android:viewportWidth="108" | ||||||
|  |     xmlns:android="http://schemas.android.com/apk/res/android"> | ||||||
|  |     <path android:fillColor="#3DDC84" | ||||||
|  |           android:pathData="M0,0h108v108h-108z"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M9,0L9,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,0L19,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M29,0L29,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M39,0L39,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M49,0L49,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M59,0L59,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M69,0L69,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M79,0L79,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M89,0L89,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M99,0L99,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,9L108,9" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,19L108,19" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,29L108,29" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,39L108,39" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,49L108,49" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,59L108,59" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,69L108,69" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,79L108,79" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,89L108,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,99L108,99" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,29L89,29" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,39L89,39" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,49L89,49" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,59L89,59" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,69L89,69" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,79L89,79" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M29,19L29,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M39,19L39,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M49,19L49,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M59,19L59,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M69,19L69,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M79,19L79,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  | </vector> | ||||||
							
								
								
									
										5
									
								
								app/src/main/res/mipmap-anydpi-v26/aether.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||||
|  |     <background android:drawable="@drawable/aether_background"/> | ||||||
|  |     <foreground android:drawable="@mipmap/aether_foreground"/> | ||||||
|  | </adaptive-icon> | ||||||
							
								
								
									
										5
									
								
								app/src/main/res/mipmap-anydpi-v26/aether_round.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||||
|  |     <background android:drawable="@drawable/aether_background"/> | ||||||
|  |     <foreground android:drawable="@mipmap/aether_foreground"/> | ||||||
|  | </adaptive-icon> | ||||||
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-hdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-hdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 5.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-hdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-mdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-mdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-mdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xhdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xhdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xhdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.0 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxhdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxhdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 29 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxhdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 9.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxxhdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 10 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxxhdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 56 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxxhdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 17 KiB | 
| @@ -3,4 +3,7 @@ plugins { | |||||||
|     alias(libs.plugins.android.application) apply false |     alias(libs.plugins.android.application) apply false | ||||||
|     alias(libs.plugins.kotlin.android) apply false |     alias(libs.plugins.kotlin.android) apply false | ||||||
|     alias(libs.plugins.kotlin.compose) apply false |     alias(libs.plugins.kotlin.compose) apply false | ||||||
|  |  | ||||||
|  |     alias(libs.plugins.hilt.android) apply false | ||||||
|  |     alias(libs.plugins.ksp) apply false | ||||||
| } | } | ||||||
| @@ -1,5 +1,7 @@ | |||||||
| [versions] | [versions] | ||||||
| agp = "8.12.1" | accompanistNavigationAnimation = "0.37.3" | ||||||
|  | agp = "8.13.0" | ||||||
|  | ariaCompiler = "latest" | ||||||
| bcprovJdk15on = "1.70" | bcprovJdk15on = "1.70" | ||||||
| bcprovJdk18on = "1.81" | bcprovJdk18on = "1.81" | ||||||
| coilCompose = "3.3.0" | coilCompose = "3.3.0" | ||||||
| @@ -7,26 +9,42 @@ coilNetworkOkhttp = "3.3.0" | |||||||
| converterGson = "3.0.0" | converterGson = "3.0.0" | ||||||
| datastorePreferences = "1.1.7" | datastorePreferences = "1.1.7" | ||||||
| exoplayerplus = "0.2.0" | exoplayerplus = "0.2.0" | ||||||
| gson = "2.13.1" | fetch2 = "3.4.1" | ||||||
| kotlin = "2.2.10" | fetch2okhttp = "3.4.1" | ||||||
|  | gson = "2.13.2" | ||||||
|  | kotlin = "2.2.20" | ||||||
| coreKtx = "1.17.0" | coreKtx = "1.17.0" | ||||||
| junit = "4.13.2" | junit = "4.13.2" | ||||||
| junitVersion = "1.3.0" | junitVersion = "1.3.0" | ||||||
| espressoCore = "3.7.0" | espressoCore = "3.7.0" | ||||||
| kotlinxSerializationJson = "1.9.0" | kotlinxSerializationJson = "1.9.0" | ||||||
| lifecycleRuntimeKtx = "2.9.2" | lifecycleRuntimeKtx = "2.9.4" | ||||||
| activityCompose = "1.10.1" | activityCompose = "1.11.0" | ||||||
| composeBom = "2025.08.00" | composeBom = "2025.09.01" | ||||||
| media3Common = "1.8.0" | media3Common = "1.8.0" | ||||||
| media3Exoplayer = "1.8.0" | media3Exoplayer = "1.8.0" | ||||||
|  | media3ExoplayerFfmpeg = "1.8.0" | ||||||
| media3Ui = "1.8.0" | media3Ui = "1.8.0" | ||||||
| navigationCompose = "2.9.3" | navigationCompose = "2.9.5" | ||||||
| okhttp = "5.1.0" | okhttp = "5.1.0" | ||||||
|  | persistentcookiejar = "1.0.1" | ||||||
|  | repo = "Tag" | ||||||
| retrofit = "3.0.0" | retrofit = "3.0.0" | ||||||
| retrofit2KotlinxSerializationConverter = "1.0.0" | retrofit2KotlinxSerializationConverter = "1.0.0" | ||||||
| media3DatasourceOkhttp = "1.8.0" | media3DatasourceOkhttp = "1.8.0" | ||||||
|  | roomCompiler = "2.8.1" | ||||||
|  | roomKtx = "2.8.1" | ||||||
|  | roomRuntime = "2.8.1" | ||||||
|  |  | ||||||
|  | ksp = "2.1.21-2.0.2" | ||||||
|  | hilt = "2.57.2" | ||||||
|  | hilt-navigation-compose = "1.3.0" | ||||||
|  | composeMaterialCore = "1.5.2" | ||||||
|  | constraintlayout = "2.2.1" | ||||||
|  | animation = "1.9.2" | ||||||
|  |  | ||||||
| [libraries] | [libraries] | ||||||
|  | accompanist-navigation-animation = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanistNavigationAnimation" } | ||||||
| androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } | ||||||
| androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } | androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } | ||||||
| androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } | androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } | ||||||
| @@ -34,10 +52,16 @@ androidx-media3-common = { module = "androidx.media3:media3-common", version.ref | |||||||
| androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } | androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } | ||||||
| androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" } | androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" } | ||||||
| androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } | androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } | ||||||
|  | androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" } | ||||||
|  | androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" } | ||||||
|  | androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } | ||||||
|  | aria-compiler = { module = "com.arialyy.aria:aria-compiler", version.ref = "ariaCompiler" } | ||||||
| bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bcprovJdk15on" } | bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bcprovJdk15on" } | ||||||
| coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } | coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } | ||||||
| coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" } | coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" } | ||||||
| converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } | converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } | ||||||
|  | fetch2 = { module = "com.github.tonyofrancis.Fetch:fetch2", version.ref = "fetch2" } | ||||||
|  | fetch2okhttp = { module = "com.github.tonyofrancis.Fetch:fetch2okhttp", version.ref = "fetch2okhttp" } | ||||||
| gson = { module = "com.google.code.gson:gson", version.ref = "gson" } | gson = { module = "com.google.code.gson:gson", version.ref = "gson" } | ||||||
| junit = { group = "junit", name = "junit", version.ref = "junit" } | junit = { group = "junit", name = "junit", version.ref = "junit" } | ||||||
| androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } | ||||||
| @@ -54,12 +78,22 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit | |||||||
| androidx-material3 = { group = "androidx.compose.material3", name = "material3" } | androidx-material3 = { group = "androidx.compose.material3", name = "material3" } | ||||||
| kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } | ||||||
| okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } | ||||||
|  | persistentcookiejar = { module = "com.github.franmontiel:PersistentCookieJar", version.ref = "persistentcookiejar" } | ||||||
| retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } | retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } | ||||||
| retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" } | retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" } | ||||||
| androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" } | androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" } | ||||||
|  |  | ||||||
|  | hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } | ||||||
|  | hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } | ||||||
|  | hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" } | ||||||
|  | androidx-compose-material-core = { group = "androidx.wear.compose", name = "compose-material-core", version.ref = "composeMaterialCore" } | ||||||
|  | androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } | ||||||
|  | androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" } | ||||||
|  |  | ||||||
| [plugins] | [plugins] | ||||||
| android-application = { id = "com.android.application", version.ref = "agp" } | android-application = { id = "com.android.application", version.ref = "agp" } | ||||||
| kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } | ||||||
| kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } | ||||||
|  |  | ||||||
|  | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } | ||||||
|  | hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } | ||||||
| @@ -16,6 +16,9 @@ dependencyResolutionManagement { | |||||||
|     repositories { |     repositories { | ||||||
|         google() |         google() | ||||||
|         mavenCentral() |         mavenCentral() | ||||||
|  |         maven { | ||||||
|  |             url = uri("https://jitpack.io") | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||