Compare commits
	
		
			73 Commits
		
	
	
		
			dev-feat1
			...
			2c4d5d2366
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 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 | ||
|   | 55fda08e06 | ||
|   | fc76e6995f | ||
|   | 3ed53ee593 | ||
|   | 6d89a6f5c2 | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -15,7 +15,7 @@ captures/ | ||||
| *.aab | ||||
| *.apk | ||||
| output-metadata.json | ||||
|  | ||||
| release/ | ||||
| # IntelliJ | ||||
| *.iml | ||||
| .idea/ | ||||
|   | ||||
							
								
								
									
										40
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -6,11 +6,33 @@ | ||||
|  | ||||
| _🚀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> | ||||
|  | ||||
| <br/> | ||||
| <br/> | ||||
| <br/> | ||||
| ## 🎯 Target | ||||
|  | ||||
| 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 | ||||
|  | ||||
| @@ -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] Optimize API call logic, do not create crashes | ||||
| - [x] Fix the issue of freezing when entering the client without configuring the private key | ||||
| - [ ] Replace Android robot icon with custom design | ||||
| - [ ] Configure server baseURL in client settings | ||||
| - [x] Replace Android robot icon with custom design | ||||
| - [x] Configure server baseURL in client settings | ||||
| - [ ] Implement proper access control for directory queries | ||||
|  | ||||
| ### Medium Priority | ||||
| - [ ] Increase minHeight for video playback | ||||
| - [ ] Add top bar with title and back button in full-screen mode | ||||
| - [ ] Optimize data transfer system | ||||
| - [ ] Improve manga/comic page display | ||||
| - [x] Increase minHeight for video playback | ||||
| - [x] Add top bar with title and back button in full-screen mode | ||||
| - [x] Optimize data transfer system | ||||
| - [x] Improve manga/comic page display | ||||
|  | ||||
| ### Future | ||||
| - [ ] (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.compose) | ||||
|     kotlin("plugin.serialization") version "1.9.0" | ||||
|  | ||||
|     alias(libs.plugins.ksp) | ||||
|     alias(libs.plugins.hilt.android) | ||||
| } | ||||
|  | ||||
| android { | ||||
| @@ -26,14 +29,16 @@ android { | ||||
|                 getDefaultProguardFile("proguard-android-optimize.txt"), | ||||
|                 "proguard-rules.pro" | ||||
|             ) | ||||
|             signingConfig = signingConfigs.getByName("debug") | ||||
|         } | ||||
|     } | ||||
|     compileOptions { | ||||
|         sourceCompatibility = JavaVersion.VERSION_11 | ||||
|         targetCompatibility = JavaVersion.VERSION_11 | ||||
|         sourceCompatibility = JavaVersion.VERSION_21 | ||||
|         targetCompatibility = JavaVersion.VERSION_21 | ||||
|     } | ||||
|     kotlinOptions { | ||||
|         jvmTarget = "11" | ||||
|         jvmTarget = "21" | ||||
|         freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode") | ||||
|     } | ||||
|     buildFeatures { | ||||
|         compose = true | ||||
| @@ -41,10 +46,27 @@ android { | ||||
| } | ||||
|  | ||||
| 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.bcprov.jdk15on) | ||||
|     implementation(libs.converter.gson) | ||||
|     implementation(libs.gson) | ||||
|  | ||||
|     implementation(libs.androidx.media3.exoplayer) | ||||
|     implementation(libs.androidx.media3.ui) | ||||
|     implementation(libs.androidx.media3.common) | ||||
| @@ -67,6 +89,7 @@ dependencies { | ||||
|     implementation(libs.androidx.ui.graphics) | ||||
|     implementation(libs.androidx.ui.tooling.preview) | ||||
|     implementation(libs.androidx.material3) | ||||
|     implementation(libs.androidx.media3.datasource.okhttp) | ||||
|     testImplementation(libs.junit) | ||||
|     androidTestImplementation(libs.androidx.junit) | ||||
|     androidTestImplementation(libs.androidx.espresso.core) | ||||
|   | ||||
| @@ -12,22 +12,31 @@ | ||||
|         android:usesCleartextTraffic="true" | ||||
|         android:dataExtractionRules="@xml/data_extraction_rules" | ||||
|         android:fullBackupContent="@xml/backup_rules" | ||||
|         android:icon="@mipmap/ic_launcher" | ||||
|         android:icon="@mipmap/aether" | ||||
|         android:label="@string/app_name" | ||||
|         android:roundIcon="@mipmap/ic_launcher_round" | ||||
|         android:roundIcon="@mipmap/aether_round" | ||||
|         android:supportsRtl="true" | ||||
|         android:theme="@style/Theme.Aether" | ||||
|         android:name=".AetherApp"> | ||||
|         <activity | ||||
|             android:name=".MainActivity" | ||||
|             android:exported="true" | ||||
|             android:label="@string/app_name" | ||||
|             android:theme="@style/Theme.Aether"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.MAIN" /> | ||||
|  | ||||
|                 <category android:name="android.intent.category.LAUNCHER" /> | ||||
|             </intent-filter> | ||||
|         </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> | ||||
| </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 | ||||
|  | ||||
| import android.app.Application | ||||
| import android.content.ComponentName | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.content.ServiceConnection | ||||
| import android.os.IBinder | ||||
| import androidx.datastore.core.DataStore | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| 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") | ||||
|  | ||||
| @HiltAndroidApp | ||||
| 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() { | ||||
|         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 { | ||||
|     var loggedIn by mutableStateOf(false) | ||||
|     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 | ||||
|  | ||||
| import android.app.Activity | ||||
| import android.content.Intent | ||||
| import androidx.compose.material.icons.Icons | ||||
| import android.graphics.drawable.Icon | ||||
| import android.net.http.SslCertificate.saveState | ||||
| import android.os.Bundle | ||||
| import android.view.View | ||||
| import android.view.WindowManager | ||||
| import androidx.activity.ComponentActivity | ||||
| import androidx.activity.compose.setContent | ||||
| import androidx.activity.enableEdgeToEdge | ||||
| import androidx.compose.animation.AnimatedContentTransitionScope | ||||
| 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.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.height | ||||
| import androidx.compose.foundation.layout.heightIn | ||||
| 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.filled.* | ||||
| import androidx.compose.material3.Card | ||||
| import androidx.compose.material3.CardDefaults | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.NavigationBar | ||||
| import androidx.compose.material3.NavigationBarItem | ||||
| 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.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.Modifier | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| 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.core.view.WindowCompat | ||||
| import androidx.core.view.WindowInsetsCompat | ||||
| import androidx.core.view.WindowInsetsControllerCompat | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| import androidx.navigation.NavController | ||||
| import androidx.navigation.NavGraph.Companion.findStartDestination | ||||
| import androidx.navigation.NavType | ||||
| @@ -47,15 +52,42 @@ import androidx.navigation.compose.composable | ||||
| import androidx.navigation.compose.currentBackStackEntryAsState | ||||
| import androidx.navigation.compose.rememberNavController | ||||
| import androidx.navigation.navArgument | ||||
| import com.acitelight.aether.view.ComicScreen | ||||
| import com.acitelight.aether.view.HomeScreen | ||||
| import com.acitelight.aether.view.MeScreen | ||||
| import com.acitelight.aether.view.VideoPlayer | ||||
| import com.acitelight.aether.view.VideoScreen | ||||
| import com.acitelight.aether.view.pages.ComicGridView | ||||
| import com.acitelight.aether.view.pages.ComicPageView | ||||
| import com.acitelight.aether.view.pages.ComicScreen | ||||
| import com.acitelight.aether.view.pages.HomeScreen | ||||
| 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() { | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         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() | ||||
|         WindowCompat.setDecorFitsSystemWindows(window, false) | ||||
|         setContent { | ||||
| @@ -67,22 +99,17 @@ class MainActivity : ComponentActivity() { | ||||
| } | ||||
|  | ||||
|  | ||||
| @Composable | ||||
| fun ToggleFullScreen(isFullScreen: Boolean) | ||||
| { | ||||
|     val view = LocalView.current | ||||
| fun setFullScreen(view: View, isFullScreen: Boolean) { | ||||
|     Global.isFullScreen = isFullScreen | ||||
|     val window = (view.context as Activity).window | ||||
|     val insetsController = WindowCompat.getInsetsController(window, view) | ||||
|  | ||||
|     LaunchedEffect(isFullScreen) { | ||||
|         val window = (view.context as Activity).window | ||||
|         val insetsController = WindowCompat.getInsetsController(window, view) | ||||
|  | ||||
|         if (isFullScreen) { | ||||
|             insetsController.hide(WindowInsetsCompat.Type.systemBars()) | ||||
|             insetsController.systemBarsBehavior = | ||||
|                 WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE | ||||
|         } else { | ||||
|             insetsController.show(WindowInsetsCompat.Type.systemBars()) | ||||
|         } | ||||
|     if (isFullScreen) { | ||||
|         insetsController.hide(WindowInsetsCompat.Type.systemBars()) | ||||
|         insetsController.systemBarsBehavior = | ||||
|             WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE | ||||
|     } else { | ||||
|         insetsController.show(WindowInsetsCompat.Type.systemBars()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -94,6 +121,8 @@ fun AppNavigation() { | ||||
|  | ||||
|     val hideBottomBarRoutes = listOf( | ||||
|         Screen.VideoPlayer.route, | ||||
|         Screen.ComicGrid.route, | ||||
|         Screen.ComicPage.route | ||||
|     ) | ||||
|     val shouldShowBottomBar = currentRoute !in hideBottomBarRoutes | ||||
|  | ||||
| @@ -106,34 +135,64 @@ fun AppNavigation() { | ||||
|             ) { | ||||
|                 BottomNavigationBar(navController = navController) | ||||
|             } | ||||
|             if(shouldShowBottomBar) | ||||
|                 ToggleFullScreen(false) | ||||
|         } | ||||
|     ) { innerPadding -> | ||||
|         NavHost( | ||||
|             navController = navController, | ||||
|             startDestination = Screen.Home.route, | ||||
|             modifier = if(shouldShowBottomBar)Modifier.padding(innerPadding) else Modifier.padding(0.dp) | ||||
|             startDestination = Screen.Me.route, | ||||
|             modifier = if(!Global.isFullScreen) Modifier.padding(innerPadding) else Modifier.padding(0.dp) | ||||
|         ) { | ||||
|             composable(Screen.Home.route) { | ||||
|                 HomeScreen(navController = navController) | ||||
|             composable( | ||||
|                 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) | ||||
|             } | ||||
|             composable(Screen.Comic.route) { | ||||
|                 ComicScreen() | ||||
|             composable(Screen.Comic.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 = "Comic") { | ||||
|                     ComicScreen(navController = navController) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             composable(Screen.Transmission.route) { | ||||
|                 // ComicScreen() | ||||
|             composable(Screen.Transmission.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 = "Tasks") { | ||||
|                     TransmissionScreen(navigator = navController) | ||||
|                 } | ||||
|             } | ||||
|             composable(Screen.Me.route) { | ||||
|             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( | ||||
|                 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 }) | ||||
|             ) { | ||||
|                 backStackEntry -> | ||||
| @@ -142,6 +201,37 @@ fun AppNavigation() { | ||||
|                     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) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -158,7 +248,7 @@ fun BottomNavigationBar(navController: NavController) { | ||||
|         Screen.Transmission, | ||||
|         Screen.Me | ||||
|     ) else  listOf( | ||||
|         Screen.Home, | ||||
|         Screen.Video, | ||||
|         Screen.Transmission, | ||||
|         Screen.Me | ||||
|     ) | ||||
| @@ -183,6 +273,37 @@ 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) { | ||||
|     data object Home : Screen("home_route", Icons.Filled.Home, "Home") | ||||
|     data object Video : Screen("video_route", Icons.Filled.VideoLibrary, "Video") | ||||
| @@ -191,4 +312,6 @@ sealed class Screen(val route: String, val icon: ImageVector, val title: String) | ||||
|         Icons.AutoMirrored.Filled.CompareArrows, "Transmission") | ||||
|     data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me") | ||||
|     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") | ||||
| } | ||||
| @@ -1,66 +0,0 @@ | ||||
| import android.content.Context | ||||
| import android.view.ViewGroup | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.DisposableEffect | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.viewinterop.AndroidView | ||||
| import androidx.media3.common.MediaItem | ||||
| import androidx.media3.common.util.UnstableApi | ||||
| import androidx.media3.datasource.ByteArrayDataSource | ||||
| import androidx.media3.datasource.DataSource | ||||
| import androidx.media3.exoplayer.ExoPlayer | ||||
| import androidx.media3.exoplayer.source.ProgressiveMediaSource | ||||
| import androidx.media3.ui.PlayerView | ||||
|  | ||||
|  | ||||
| @androidx.annotation.OptIn(UnstableApi::class) | ||||
| @Composable | ||||
| private fun InMemoryVideoPlayer( | ||||
|     modifier: Modifier = Modifier, | ||||
|     videoData: ByteArray | ||||
| ) { | ||||
|     val context = LocalContext.current | ||||
|  | ||||
|     val exoPlayer = remember(context, videoData) { | ||||
|         createExoPlayer(context, videoData) | ||||
|     } | ||||
|  | ||||
|     DisposableEffect(Unit) { | ||||
|         onDispose { | ||||
|             exoPlayer.release() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     AndroidView( | ||||
|         modifier = modifier, | ||||
|         factory = { | ||||
|             PlayerView(it).apply { | ||||
|                 player = exoPlayer | ||||
|                 layoutParams = ViewGroup.LayoutParams( | ||||
|                     ViewGroup.LayoutParams.MATCH_PARENT, | ||||
|                     ViewGroup.LayoutParams.MATCH_PARENT | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @androidx.annotation.OptIn(UnstableApi::class) | ||||
| private fun createExoPlayer(context: Context, videoData: ByteArray): ExoPlayer { | ||||
|     val byteArrayDataSource = ByteArrayDataSource(videoData) | ||||
|  | ||||
|     val factory = DataSource.Factory { | ||||
|         byteArrayDataSource | ||||
|     } | ||||
|  | ||||
|     val mediaSource = ProgressiveMediaSource.Factory(factory) | ||||
|         .createMediaSource(MediaItem.fromUri("data://local/video.mp4")) | ||||
|  | ||||
|     return ExoPlayer.Builder(context).build().apply { | ||||
|         setMediaSource(mediaSource) | ||||
|         prepare() | ||||
|         playWhenReady = false | ||||
|     } | ||||
| } | ||||
| @@ -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 | ||||
|  | ||||
| import kotlinx.serialization.Serializable | ||||
|  | ||||
| @Serializable | ||||
| data class BookMark( | ||||
|     val name: String, | ||||
|     val page: String | ||||
|   | ||||
| @@ -1,8 +1,81 @@ | ||||
| package com.acitelight.aether.model | ||||
|  | ||||
| data class Comic( | ||||
|     val comic_name: String, | ||||
|     val page_count: Int, | ||||
|     val bookmarks: List<BookMark>, | ||||
|     val pages: List<String> | ||||
| ) | ||||
| import com.acitelight.aether.service.ApiClient | ||||
|  | ||||
| class Comic( | ||||
|     val comic: ComicResponse, | ||||
|     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 | ||||
|  | ||||
| import kotlinx.serialization.Serializable | ||||
|  | ||||
|  | ||||
| @Serializable | ||||
| data class Comment( | ||||
|     val content: String, | ||||
|     val username: String, | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| package com.acitelight.aether.model | ||||
|  | ||||
| data class KeyImage( | ||||
|     val name: String, | ||||
|     val url: String, | ||||
|     val key: String | ||||
| ) | ||||
|   | ||||
| @@ -1,29 +1,64 @@ | ||||
| package com.acitelight.aether.model | ||||
|  | ||||
| import com.acitelight.aether.service.ApiClient | ||||
| import kotlinx.serialization.Serializable | ||||
| import java.security.KeyPair | ||||
|  | ||||
| class Video constructor( | ||||
|  | ||||
| @Serializable | ||||
| class Video( | ||||
|     val isLocal: Boolean, | ||||
|     val localBase: String, | ||||
|     val klass: String, | ||||
|     val id: String, | ||||
|     val token: String, | ||||
|     val video: VideoResponse | ||||
|     ){ | ||||
|     fun getCover(): String | ||||
|     { | ||||
|         return "${ApiClient.base}api/video/$klass/$id/cover?token=$token" | ||||
| ) { | ||||
|     fun getCover(api: ApiClient): String { | ||||
|         return if (isLocal) | ||||
|             "$localBase/videos/$klass/$id/cover.jpg" | ||||
|         else | ||||
|             "${api.getBase()}api/video/$klass/$id/cover" | ||||
|     } | ||||
|  | ||||
|     fun getVideo(): String | ||||
|     { | ||||
|         return "${ApiClient.base}api/video/$klass/$id/av?token=$token" | ||||
|     fun getVideo(api: ApiClient): String { | ||||
|         return if (isLocal) | ||||
|             "$localBase/videos/$klass/$id/video.mp4" | ||||
|         else | ||||
|             "${api.getBase()}api/video/$klass/$id/av" | ||||
|     } | ||||
|  | ||||
|     fun getGallery(): List<KeyImage> | ||||
|     { | ||||
|         return video.gallery.map{ | ||||
|             KeyImage(url = "${ApiClient.base}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it") | ||||
|     fun getSubtitle(api: ApiClient): String { | ||||
|         return if (isLocal) | ||||
|             "$localBase/videos/$klass/$id/subtitle.vtt" | ||||
|         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 | ||||
|  | ||||
| import kotlinx.serialization.Serializable | ||||
|  | ||||
| @Serializable | ||||
| data class VideoResponse( | ||||
|     val name: String, | ||||
|     val duration: Long, | ||||
| @@ -7,5 +10,6 @@ data class VideoResponse( | ||||
|     val comment: List<Comment>, | ||||
|     val star: Boolean, | ||||
|     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,67 @@ | ||||
| package com.acitelight.aether.service | ||||
|  | ||||
| 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 dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.withContext | ||||
| 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.Companion.toHttpUrlOrNull | ||||
| import okhttp3.JavaNetCookieJar | ||||
| import okhttp3.MediaType.Companion.toMediaType | ||||
| import okhttp3.OkHttpClient | ||||
| import retrofit2.Retrofit | ||||
| import retrofit2.converter.gson.GsonConverterFactory | ||||
| 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.cert.Certificate | ||||
| import java.security.cert.CertificateException | ||||
| import java.security.cert.CertificateFactory | ||||
| import java.security.cert.X509Certificate | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Singleton | ||||
| import javax.net.ssl.SSLContext | ||||
| import javax.net.ssl.TrustManagerFactory | ||||
| import javax.net.ssl.X509TrustManager | ||||
|  | ||||
| object ApiClient { | ||||
|     var base: String = "" | ||||
|     var domain: String = "" | ||||
|     var cert: String = "" | ||||
| @Singleton | ||||
| class ApiClient @Inject constructor( | ||||
|     @ApplicationContext private val context: Context, | ||||
| ) { | ||||
|     fun getBase(): String{ | ||||
|         return replaceAbyssProtocol(base) | ||||
|     } | ||||
|     private var base: String = "" | ||||
|     private var domain: String = "" | ||||
|     private var cert: String = "" | ||||
|     private val json = Json { | ||||
|         ignoreUnknownKeys = true | ||||
|     } | ||||
|  | ||||
|     fun loadCertificateFromString(pemString: String): X509Certificate { | ||||
|     private fun replaceAbyssProtocol(uri: String): String { | ||||
|         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 decodedPem = pemString | ||||
|             .replace("-----BEGIN CERTIFICATE-----", "") | ||||
| @@ -41,61 +75,191 @@ object ApiClient { | ||||
|             return certificateFactory.generateCertificate(inputStream) as X509Certificate | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate): OkHttpClient { | ||||
|     private fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate?): OkHttpClient { | ||||
|         try { | ||||
|             val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { | ||||
|                 load(null, null) | ||||
|                 setCertificateEntry("ca", trustedCert) | ||||
|             val defaultTmFactory = TrustManagerFactory.getInstance( | ||||
|                 TrustManagerFactory.getDefaultAlgorithm() | ||||
|             ).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 tmf = TrustManagerFactory.getInstance(tmfAlgorithm).apply { | ||||
|                 init(keyStore) | ||||
|             } | ||||
|             val combinedTm = object : X509TrustManager { | ||||
|                 override fun getAcceptedIssuers(): Array<X509Certificate> { | ||||
|                     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 { | ||||
|                 init(null, arrayOf(trustManager), null) | ||||
|                 init(null, arrayOf(combinedTm), null) | ||||
|             } | ||||
|  | ||||
|             return OkHttpClient.Builder() | ||||
|                 .sslSocketFactory(sslContext.socketFactory, trustManager) | ||||
|                 .build() | ||||
|             return if (base.startsWith("abyss://")) | ||||
|                 OkHttpClient.Builder() | ||||
|                     .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) { | ||||
|             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)) | ||||
|  | ||||
|     } | ||||
|     private fun createRetrofit(): Retrofit { | ||||
|         val okHttpClient = createOkHttpClientWithDynamicCert(loadCertificateFromString(cert)) | ||||
|         client = createOkHttp() | ||||
|         val b = replaceAbyssProtocol(base) | ||||
|  | ||||
|         return Retrofit.Builder() | ||||
|             .baseUrl(base) | ||||
|             .client(okHttpClient) | ||||
|             .baseUrl(b) | ||||
|             .client(client!!) | ||||
|             .addConverterFactory(GsonConverterFactory.create()) | ||||
|             .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) | ||||
|             .build() | ||||
|     } | ||||
|  | ||||
|  | ||||
|     private var client: OkHttpClient? = null | ||||
|     var api: ApiInterface? = null | ||||
|  | ||||
|     fun apply(url: String, crt: String) | ||||
|     { | ||||
|         try{ | ||||
|             domain = url.toHttpUrlOrNull()?.host !! | ||||
|     fun getClient() = client!! | ||||
|  | ||||
|     suspend fun apply(context: Context, urls: String, crt: String): String? { | ||||
|         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 = selectedUrl.toHttpUrlOrNull()?.host ?: "" | ||||
|             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) | ||||
|         }catch (e: Exception) | ||||
|         { | ||||
|             return base | ||||
|         } catch (_: Exception) { | ||||
|             api = null | ||||
|             base = "" | ||||
|             domain = "" | ||||
|             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 | ||||
|  | ||||
| import com.acitelight.aether.model.BookMark | ||||
| 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 okhttp3.ResponseBody | ||||
| import retrofit2.http.Body | ||||
| import retrofit2.http.GET | ||||
| import retrofit2.http.POST | ||||
| import retrofit2.http.Path | ||||
| import retrofit2.http.Query | ||||
| import retrofit2.http.Streaming | ||||
|  | ||||
| interface ApiInterface { | ||||
|     @GET("api/video") | ||||
|     suspend fun getVideoClasses( | ||||
|         @Query("token") token: String | ||||
|     ): List<String> | ||||
|     @GET("api/video/{klass}") | ||||
|     suspend fun queryVideoClasses( | ||||
|         @Path("klass") klass: String, | ||||
|         @Query("token") token: String | ||||
|         @Path("klass") klass: String | ||||
|     ): List<String> | ||||
|     @GET("api/video/{klass}/{id}") | ||||
|     suspend fun queryVideo( | ||||
|         @Path("klass") klass: String, | ||||
|         @Path("id") id: String, | ||||
|         @Query("token") token: String | ||||
|         @Path("id") id: String | ||||
|     ): VideoResponse | ||||
|  | ||||
|     @GET("api/video/{klass}/{id}/nv") | ||||
|     @Streaming | ||||
|     suspend fun getNailVideo( | ||||
|     @POST("api/video/{klass}/bulkquery") | ||||
|     suspend fun queryVideoBulk( | ||||
|         @Path("klass") klass: String, | ||||
|         @Path("id") id: String, | ||||
|         @Query("token") token: String | ||||
|     ): ResponseBody | ||||
|         @Body() id: List<String> | ||||
|     ): List<VideoResponse> | ||||
|  | ||||
|     @GET("api/image/collections") | ||||
|     suspend fun getComicCollections(): List<String> | ||||
|     @GET("api/image/meta") | ||||
|     suspend fun queryComicInfo(@Query("collection") collection: String): Comic | ||||
|     @GET("api/image") | ||||
|     suspend fun getComics(): List<String> | ||||
|     @GET("api/image/{id}") | ||||
|     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}") | ||||
|     suspend fun getChallenge( | ||||
| @@ -52,4 +52,7 @@ interface ApiInterface { | ||||
|         @Path("user") user: String, | ||||
|         @Body challengeResponse: ChallengeResponse | ||||
|     ): ResponseBody | ||||
|  | ||||
|     @GET("api/abyss") | ||||
|     suspend fun hello(): ResponseBody | ||||
| } | ||||
|   | ||||
| @@ -1,29 +1,45 @@ | ||||
| package com.acitelight.aether.service | ||||
|  | ||||
| import android.util.Base64 | ||||
| import android.util.Log | ||||
| import com.acitelight.aether.model.ChallengeResponse | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| 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.signers.Ed25519Signer | ||||
| import java.io.IOException | ||||
| import java.lang.reflect.Proxy | ||||
| import java.net.InetSocketAddress | ||||
| import java.security.PrivateKey | ||||
| 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? { | ||||
|         val api = ApiClient.api | ||||
|         val api = apiClient.api | ||||
|         var challengeBase64 = "" | ||||
|  | ||||
|         try{ | ||||
|             challengeBase64 = api!!.getChallenge(username).string() | ||||
|         }catch (e: Exception) | ||||
|         { | ||||
|             print(e.message) | ||||
|             return null | ||||
|         } | ||||
|  | ||||
|         val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64)) | ||||
|  | ||||
|         return try { | ||||
|             api!!.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string() | ||||
|             api.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string() | ||||
|         } catch (e: Exception) { | ||||
|             e.printStackTrace() | ||||
|             null | ||||
| @@ -44,4 +60,14 @@ object AuthManager { | ||||
|         val signature = signer.generateSignature() | ||||
|         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,61 +1,198 @@ | ||||
| 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.Video | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.withContext | ||||
| import java.io.IOException | ||||
| import com.acitelight.aether.model.VideoDownloadItemState | ||||
| import com.tonyodev.fetch2.Status | ||||
| 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> | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             val j = ApiClient.api!!.getVideoClasses(token) | ||||
|             val j = apiClient.api!!.getVideoClasses() | ||||
|             return j.toList() | ||||
|         }catch(e: Exception) | ||||
|         }catch(_: Exception) | ||||
|         { | ||||
|             return listOf() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun listVideos(klass: String): List<Video> | ||||
|     suspend fun queryVideoKlasses(klass: String): List<String> | ||||
|     { | ||||
|         try { | ||||
|             val j = ApiClient.api!!.queryVideoClasses(klass, token) | ||||
|             return j.map{ | ||||
|                 queryVideo(klass, it)!! | ||||
|             }.toList() | ||||
|         }catch (e: Exception) | ||||
|         try | ||||
|         { | ||||
|             val j = apiClient.api!!.queryVideoClasses(klass) | ||||
|             return j.toList() | ||||
|         }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 { | ||||
|             val j = ApiClient.api!!.queryVideo(klass, id, token) | ||||
|             return Video(klass = klass, id = id, token=token, j) | ||||
|         }catch (e: Exception) | ||||
|             val j = apiClient.api!!.queryVideo(klass, id) | ||||
|             return Video(klass = klass, id = id, isLocal = false, localBase = "", video = j) | ||||
|         }catch (_: Exception) | ||||
|         { | ||||
|             return null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun listComics() : List<String> | ||||
|     suspend fun queryVideo(klass: String, id: String): Video? | ||||
|     { | ||||
|         // TODO: try | ||||
|         return ApiClient.api!!.getComicCollections() | ||||
|         val downloaded = fetchManager.getAllDownloadsAsync().filter { | ||||
|             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 | ||||
|         return ApiClient.api!!.queryComicInfo(c) | ||||
|         try{ | ||||
|             val j = apiClient.api!!.getComics() | ||||
|             return j | ||||
|         }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 androidx.compose.runtime.mutableStateListOf | ||||
| import com.acitelight.aether.model.Comic | ||||
| import com.acitelight.aether.model.Video | ||||
| import com.acitelight.aether.model.VideoQueryIndex | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import kotlinx.coroutines.sync.Mutex | ||||
| import kotlinx.coroutines.sync.withLock | ||||
| import kotlinx.coroutines.withContext | ||||
| @@ -14,26 +13,31 @@ import kotlinx.serialization.json.* | ||||
| import java.io.File | ||||
| import java.io.FileNotFoundException | ||||
| 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() | ||||
|  | ||||
|     suspend fun readFile(context: Context, filename: String): String { | ||||
|     private suspend fun readFile(context: Context, filename: String): String { | ||||
|         return withContext(Dispatchers.IO) { | ||||
|             try { | ||||
|                 val file = File(context.filesDir, filename) | ||||
|                 val content = file.readText() | ||||
|                 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) { | ||||
|             try { | ||||
|                 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") | ||||
|         try{ | ||||
|             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) | ||||
|                     recent.add(recent.size, v) | ||||
|                     for(j in v) | ||||
|                     { | ||||
|                         recentVideo.add(recentVideo.size, j) | ||||
|                     } | ||||
|             } | ||||
|  | ||||
|             return r | ||||
| @@ -69,32 +146,26 @@ object RecentManager | ||||
|         return listOf() | ||||
|     } | ||||
|  | ||||
|     suspend fun Push(context: Context, video: VideoQueryIndex) | ||||
|     suspend fun pushVideo(context: Context, video: VideoQueryIndex) | ||||
|     { | ||||
|         mutex.withLock{ | ||||
|             val content = readFile(context, "recent.json") | ||||
|             var o = Json.decodeFromString<List<VideoQueryIndex>>(content).toMutableList(); | ||||
|             val o = recentVideo.map{ VideoQueryIndex(it.klass, it.id) }.toMutableList() | ||||
|  | ||||
|             if(o.contains(video)) | ||||
|             { | ||||
|                 val temp = o[0] | ||||
|                 val index = o.indexOf(video) | ||||
|                 recent.removeAt(index) | ||||
|                 o[0] = o[index] | ||||
|                 o[index] = temp | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 o.add(0, video) | ||||
|                 recentVideo.removeAt(index) | ||||
|             } | ||||
|             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)!!) | ||||
|             writeFile(context, "recent.json", Json.encodeToString(o)) | ||||
|             if(recentVideo.size >= 21) | ||||
|                 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.os.Build | ||||
| import androidx.compose.foundation.isSystemInDarkTheme | ||||
| import androidx.compose.material3.ColorScheme | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.darkColorScheme | ||||
| import androidx.compose.material3.dynamicDarkColorScheme | ||||
| import androidx.compose.material3.dynamicLightColorScheme | ||||
| import androidx.compose.material3.lightColorScheme | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.graphics.lerp | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
|  | ||||
| private val DarkColorScheme = darkColorScheme( | ||||
|     primary = Purple80, | ||||
|     secondary = PurpleGrey80, | ||||
|     tertiary = Pink80 | ||||
| ) | ||||
| fun generateColorScheme(primaryColor: Color, isDarkMode: Boolean): ColorScheme { | ||||
|  | ||||
| private val LightColorScheme = lightColorScheme( | ||||
|     primary = Purple40, | ||||
|     secondary = PurpleGrey40, | ||||
|     tertiary = Pink40 | ||||
|     val background = if (isDarkMode) Color(0xFF121212) else Color(0xFFFFFFFF) | ||||
|     val surface = if (isDarkMode) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) | ||||
|  | ||||
|     /* Other default colors to override | ||||
|     background = Color(0xFFFFFBFE), | ||||
|     surface = Color(0xFFFFFBFE), | ||||
|     onPrimary = Color.White, | ||||
|     onSecondary = Color.White, | ||||
|     onTertiary = Color.White, | ||||
|     onBackground = Color(0xFF1C1B1F), | ||||
|     onSurface = Color(0xFF1C1B1F), | ||||
|     */ | ||||
| ) | ||||
|     val surfaceContainer = if (isDarkMode) Color(0xFF232323) else Color(0xFFFDFDFD) | ||||
|     val surfaceContainerLow = if (isDarkMode) Color(0xFF1A1A1A) else Color(0xFFF5F5F5) | ||||
|     val surfaceContainerHigh = if (isDarkMode) Color(0xFF2A2A2A) else Color(0xFFFAFAFA) | ||||
|     val surfaceContainerHighest = if (isDarkMode) Color(0xFF333333) else Color(0xFFFFFFFF) | ||||
|     val surfaceContainerLowest = if (isDarkMode) Color(0xFF0F0F0F) else Color(0xFFF0F0F0) | ||||
|     val surfaceBright = if (isDarkMode) Color(0xFF2C2C2C) else Color(0xFFFFFFFF) | ||||
|     val surfaceDim = if (isDarkMode) Color(0xFF141414) else Color(0xFFF8F8F8) | ||||
|  | ||||
|     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 | ||||
| fun AetherTheme( | ||||
| @@ -41,11 +141,6 @@ fun AetherTheme( | ||||
|     content: @Composable () -> Unit | ||||
| ) { | ||||
|     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 | ||||
|         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) | ||||
|                         }) | ||||
|                     HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color) | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -1,982 +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.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) | ||||
|                         }) | ||||
|                     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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun SingleImageItem(img: KeyImage) { | ||||
|     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 | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @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) | ||||
| { | ||||
|     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 | ||||
|             ) | ||||
|  | ||||
|             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,181 +0,0 @@ | ||||
| package com.acitelight.aether.view | ||||
|  | ||||
| import android.R.id.tabs | ||||
| 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.MaterialTheme | ||||
| import androidx.compose.material3.PrimaryScrollableTabRow | ||||
| import androidx.compose.material3.Tab | ||||
| import androidx.compose.material3.TabRow | ||||
| import androidx.compose.material3.TabRowDefaults | ||||
| import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset | ||||
| 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.service.MediaManager | ||||
| import com.acitelight.aether.viewModel.VideoScreenViewModel | ||||
| import androidx.compose.material3.PrimaryTabRow | ||||
| import androidx.compose.material3.ScrollableTabRow | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.graphics.Brush | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.text.style.TextOverflow | ||||
| import androidx.navigation.NavHostController | ||||
| import coil3.request.ImageRequest | ||||
| import com.acitelight.aether.Global | ||||
| import kotlinx.coroutines.flow.first | ||||
| 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 videoList by videoScreenViewModel.videos.collectAsState() | ||||
|  | ||||
|     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) | ||||
|         ) | ||||
|         { | ||||
|             items(videoList) { video -> | ||||
|                 VideoCard(video, navController, videoScreenViewModel) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @OptIn(ExperimentalMaterial3Api::class) | ||||
| @Composable | ||||
| fun TopRow(videoScreenViewModel: VideoScreenViewModel) | ||||
| { | ||||
|     val tabIndex by videoScreenViewModel.tabIndex; | ||||
|     val klasses by videoScreenViewModel.klasses.collectAsState(); | ||||
|  | ||||
|     if(klasses.isEmpty()) return | ||||
|  | ||||
|     ScrollableTabRow (selectedTabIndex = tabIndex) { | ||||
|         klasses.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 videoList by videoScreenViewModel.videos.collectAsState() | ||||
|  | ||||
|     Card( | ||||
|         shape = RoundedCornerShape(6.dp), | ||||
|         modifier = Modifier | ||||
|             .fillMaxWidth() | ||||
|             .wrapContentHeight(), | ||||
|         onClick = { | ||||
|             Global.sameClassVideos = videoList | ||||
|             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 | ||||
|                 ) | ||||
|  | ||||
|                 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,121 @@ | ||||
| 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, | ||||
|                     imageLoader = comicScreenViewModel.imageLoader!!, | ||||
|                     modifier = Modifier | ||||
|                         .fillMaxSize() | ||||
|                         .clip(RoundedCornerShape(8.dp)), | ||||
|                     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, | ||||
|                         maxLines = 1 | ||||
|                     ) | ||||
|                 } | ||||
|             } | ||||
|             Text( | ||||
|                 text = comic.comic.comic_name, | ||||
|                 fontSize = 14.sp, | ||||
|                 lineHeight = 17.sp, | ||||
|                 fontWeight = FontWeight.Bold, | ||||
|                 maxLines = 2, | ||||
|                 modifier = Modifier.padding(4.dp) | ||||
|             ) | ||||
|  | ||||
|             Box(Modifier.padding(4.dp).fillMaxWidth()){ | ||||
|                 Text( | ||||
|                     text = "Id: ${comic.id}", | ||||
|                     fontSize = 12.sp, | ||||
|                     lineHeight = 14.sp, | ||||
|                     maxLines = 1, | ||||
|                     modifier = Modifier.align(Alignment.CenterStart) | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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,614 @@ | ||||
| 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.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.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 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() | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|     val name by videoPlayerViewModel.currentName | ||||
|     val id by videoPlayerViewModel.currentId | ||||
|  | ||||
|     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.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(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(",").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(Unit) { | ||||
|         comicGridViewModel.coverHeight = screenHeight * 0.4f | ||||
|         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) | ||||
|                         ) | ||||
|  | ||||
|                         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 = 14.sp, | ||||
|                 fontWeight = FontWeight.Bold, | ||||
|                 maxLines = 2, | ||||
|                 lineHeight = 16.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, | ||||
|                 fontWeight = FontWeight.Bold, | ||||
|                 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,329 @@ | ||||
| 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.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.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.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.Color | ||||
| 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.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) | ||||
|                     .clickable { | ||||
|                         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) | ||||
|             ) { | ||||
|                 Box() | ||||
|                 { | ||||
|                     Column(Modifier | ||||
|                         .align(Alignment.TopCenter) | ||||
|                         .fillMaxWidth()) | ||||
|                     { | ||||
|                         Card( | ||||
|                             colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||
|                             shape = RoundedCornerShape(12.dp), | ||||
|                             modifier = Modifier | ||||
|                                 .fillMaxWidth() | ||||
|                                 .padding(top = 18.dp) | ||||
|                                 .padding(horizontal = 12.dp) | ||||
|                                 .height(42.dp) | ||||
|                         ) | ||||
|                         { | ||||
|                             Row(modifier = Modifier.fillMaxSize()) | ||||
|                             { | ||||
|                                 Text( | ||||
|                                     text = title, | ||||
|                                     fontSize = 16.sp, | ||||
|                                     fontWeight = FontWeight.Bold, | ||||
|                                     maxLines = 1, | ||||
|                                     modifier = Modifier | ||||
|                                         .padding(8.dp) | ||||
|                                         .padding(horizontal = 10.dp) | ||||
|                                         .weight(1f) | ||||
|                                         .align(Alignment.CenterVertically) | ||||
|                                 ) | ||||
|  | ||||
|                                 Text( | ||||
|                                     text = "${pagerState.currentPage + 1}/${pagerState.pageCount}", | ||||
|                                     fontSize = 18.sp, | ||||
|                                     fontWeight = FontWeight.Bold, | ||||
|                                     maxLines = 1, | ||||
|                                     modifier = Modifier | ||||
|                                         .padding(8.dp) | ||||
|                                         .widthIn(min = 60.dp) | ||||
|                                         .align(Alignment.CenterVertically) | ||||
|                                 ) | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                         Box(Modifier.fillMaxWidth()) { | ||||
|                             Card( | ||||
|                                 modifier = Modifier | ||||
|                                     .align(Alignment.CenterStart) | ||||
|                                     .padding(top = 6.dp) | ||||
|                                     .padding(horizontal = 12.dp) | ||||
|                                     .height(42.dp), | ||||
|                                 colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||
|                                 shape = RoundedCornerShape(12.dp) | ||||
|                             ) | ||||
|                             { | ||||
|                                 Row { | ||||
|                                     val k = it.getPageChapterIndex(pagerState.currentPage) | ||||
|                                     Text( | ||||
|                                         text = k.first.name, | ||||
|                                         fontSize = 16.sp, | ||||
|                                         fontWeight = FontWeight.Bold, | ||||
|                                         maxLines = 1, | ||||
|                                         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, | ||||
|                                         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.primary), | ||||
|                                 shape = RoundedCornerShape(12.dp) | ||||
|                             ) | ||||
|                             { | ||||
|                                 Box(Modifier.clickable { | ||||
|                                     showBookMarkPop = true | ||||
|                                 }) { | ||||
|                                     Icon( | ||||
|                                         Icons.Filled.Bookmarks, | ||||
|                                         modifier = Modifier | ||||
|                                             .padding(8.dp), | ||||
|                                         contentDescription = "Bookmark" | ||||
|                                     ) | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             AnimatedVisibility( | ||||
|                 visible = showPlane, | ||||
|                 enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }), | ||||
|                 exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }), | ||||
|                 modifier = Modifier | ||||
|                     .align(Alignment.BottomCenter) | ||||
|             ) | ||||
|             { | ||||
|                 Box { | ||||
|                     LazyRow( | ||||
|                         horizontalArrangement = Arrangement.spacedBy(5.dp), | ||||
|                         state = comicPageViewModel.listState!!, modifier = Modifier | ||||
|                             .fillMaxWidth() | ||||
|                             .padding(bottom = 18.dp) | ||||
|                             .padding(horizontal = 12.dp) | ||||
|                             .height(180.dp) | ||||
|                             .align(Alignment.BottomCenter) | ||||
|                     ) | ||||
|                     { | ||||
|                         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(1.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) | ||||
|                                             ) | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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,238 @@ | ||||
| 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 = 88.dp) | ||||
|                 .padding(4.dp), | ||||
|             rowHeight = 32.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) | ||||
|             ) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,22 +1,23 @@ | ||||
| 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.Column | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.layout.width | ||||
| import androidx.compose.foundation.lazy.LazyColumn | ||||
| import androidx.compose.foundation.text.KeyboardOptions | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.Key | ||||
| import androidx.compose.material.icons.filled.Link | ||||
| import androidx.compose.material.icons.filled.Person | ||||
| import androidx.compose.material.icons.filled.Security | ||||
| import androidx.compose.material3.Button | ||||
| import androidx.compose.material3.Card | ||||
| import androidx.compose.material3.Checkbox | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.OutlinedTextField | ||||
| @@ -27,22 +28,22 @@ import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.text.TextStyle | ||||
| import androidx.compose.ui.text.input.KeyboardType | ||||
| import androidx.compose.ui.unit.dp | ||||
| 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 | ||||
| 
 | ||||
| @Composable | ||||
| fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) { | ||||
|     val context = LocalContext.current | ||||
|     var username by meScreenViewModel.username; | ||||
|     var privateKey by meScreenViewModel.privateKey; | ||||
| fun MeScreen(meScreenViewModel: MeScreenViewModel = hiltViewModel<MeScreenViewModel>()) { | ||||
|     var username by meScreenViewModel.username | ||||
|     var privateKey by meScreenViewModel.privateKey | ||||
|     var url by meScreenViewModel.url | ||||
|     var cert by meScreenViewModel.cert | ||||
| 
 | ||||
|     val uss by meScreenViewModel.uss.collectAsState(initial = false) | ||||
| 
 | ||||
|     LazyColumn( | ||||
|         modifier = Modifier | ||||
|             .fillMaxSize() | ||||
| @@ -104,9 +105,10 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) { | ||||
|                     // Save Button | ||||
|                     Button( | ||||
|                         onClick = { | ||||
|                             meScreenViewModel.updateAccount(username, privateKey, context) | ||||
|                             meScreenViewModel.updateAccount(username, privateKey) | ||||
|                         }, | ||||
|                         modifier = Modifier.fillMaxWidth() | ||||
|                         modifier = Modifier.fillMaxWidth(), | ||||
|                         enabled = privateKey != "******" | ||||
|                     ) { | ||||
|                         Text("Save") | ||||
|                     } | ||||
| @@ -133,6 +135,8 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) { | ||||
|                             .align(Alignment.Start) | ||||
|                     ) | ||||
| 
 | ||||
|                     Spacer(modifier = Modifier.width(8.dp)) | ||||
| 
 | ||||
|                     // Username input field | ||||
|                     OutlinedTextField( | ||||
|                         value = url, | ||||
| @@ -145,32 +149,50 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) { | ||||
|                         modifier = Modifier.fillMaxWidth() | ||||
|                     ) | ||||
| 
 | ||||
|                     Spacer(modifier = Modifier.height(16.dp)) | ||||
|                     Spacer(modifier = Modifier.height(4.dp)) | ||||
| 
 | ||||
|                     // Private key input field | ||||
|                     OutlinedTextField( | ||||
|                         value = cert, | ||||
|                         onValueChange = { cert = it }, | ||||
|                         label = { Text("Cert") }, | ||||
|                         singleLine = false, | ||||
|                         maxLines = 40, | ||||
|                         minLines = 20, | ||||
|                         modifier = Modifier.fillMaxWidth(), | ||||
|                         textStyle = TextStyle( | ||||
|                             fontSize = 8.sp | ||||
|                     Row(Modifier.align(Alignment.Start)) { | ||||
|                         Checkbox( | ||||
|                             checked = uss, | ||||
|                             onCheckedChange = { isChecked -> | ||||
|                                 meScreenViewModel.onUseSelfSignedCheckedChange(isChecked) | ||||
|                             }, | ||||
|                             modifier = Modifier.align(Alignment.CenterVertically) | ||||
|                         ) | ||||
|                         Spacer(modifier = Modifier.width(4.dp)) | ||||
|                         Text( | ||||
|                             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)) | ||||
| 
 | ||||
|                     // Save Button | ||||
|                     Button( | ||||
|                         onClick = { | ||||
|                             meScreenViewModel.updateServer(url, cert, context) | ||||
|                         }, | ||||
|                         modifier = Modifier.fillMaxWidth() | ||||
|                     ) { | ||||
|                         Text("Save") | ||||
|                     Row{ | ||||
|                         Button( | ||||
|                             onClick = { | ||||
|                                 meScreenViewModel.updateServer(url, cert) | ||||
|                             }, | ||||
|                             modifier = Modifier.weight(0.5f).padding(8.dp) | ||||
|                         ) { | ||||
|                             Text("Save") | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| @@ -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 = 220.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 | ||||
|  | ||||
| import android.content.Context | ||||
| import androidx.compose.runtime.mutableStateListOf | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| 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.Video | ||||
| import com.acitelight.aether.service.ApiClient | ||||
| import com.acitelight.aether.service.MediaManager | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import dagger.hilt.android.lifecycle.HiltViewModel | ||||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import kotlinx.coroutines.launch | ||||
| import javax.inject.Inject | ||||
|  | ||||
| class ComicScreenViewModel : ViewModel() | ||||
| { | ||||
|     private val _comics = MutableStateFlow<List<Comic>>(emptyList()) | ||||
|     val comics: StateFlow<List<Comic>> = _comics | ||||
| @HiltViewModel | ||||
| class ComicScreenViewModel @Inject constructor( | ||||
|     @ApplicationContext private val context: Context, | ||||
|     val mediaManager: MediaManager, | ||||
|     val apiClient: ApiClient | ||||
| ) : ViewModel() { | ||||
|  | ||||
|     init | ||||
|     { | ||||
|       //  viewModelScope.launch { | ||||
|       //      val l = MediaManager.listComics() | ||||
|        //     _comics.value = l.map { MediaManager.queryComicInfo(it) } | ||||
|        // } | ||||
|     var imageLoader: ImageLoader? = null; | ||||
|  | ||||
|     val searchFilter = mutableStateOf("") | ||||
|     val comics = mutableStateListOf<Comic>() | ||||
|     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.sortedWith(compareBy(naturalOrder()) { it.comic.comic_name })) | ||||
|                 tags.addAll(m.flatMap { it.comic.tags }.groupingBy { it }.eachCount() | ||||
|                     .entries.sortedByDescending { it.value } | ||||
|                     .map { it.key }) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,95 +1,38 @@ | ||||
| package com.acitelight.aether.viewModel | ||||
|  | ||||
| import android.app.Application | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import android.content.Context | ||||
| 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 com.acitelight.aether.Global | ||||
| import com.acitelight.aether.dataStore | ||||
| import com.acitelight.aether.model.Video | ||||
| import com.acitelight.aether.model.VideoQueryIndex | ||||
| import coil3.ImageLoader | ||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||
| import com.acitelight.aether.service.ApiClient | ||||
| 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 kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import kotlinx.coroutines.flow.first | ||||
| import kotlinx.coroutines.flow.map | ||||
| import com.acitelight.aether.service.VideoLibrary | ||||
| 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 | ||||
|     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") | ||||
|     var imageLoader: ImageLoader? = null | ||||
|  | ||||
|     val userNameFlow: Flow<String> = dataStore.data.map { preferences -> | ||||
|         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 | ||||
|  | ||||
|     @Composable | ||||
|     fun Init(){ | ||||
|         if(_init) return | ||||
|         _init = true | ||||
|  | ||||
|         val context = LocalContext.current | ||||
|         remember { | ||||
|             viewModelScope.launch { | ||||
|                 RecentManager.Query(context) | ||||
|     init{ | ||||
|         imageLoader =  ImageLoader.Builder(context) | ||||
|             .components { | ||||
|                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     init { | ||||
|             .build() | ||||
|         viewModelScope.launch { | ||||
|             val u = userNameFlow.first() | ||||
|             val p = privateKeyFlow.first() | ||||
|             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) | ||||
|             } | ||||
|             recentManager.queryVideo(context) | ||||
|             recentManager.queryComic(context) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,119 +1,140 @@ | ||||
| package com.acitelight.aether.viewModel | ||||
|  | ||||
| import android.app.Application | ||||
| import android.content.Context | ||||
| import android.widget.Toast | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.datastore.preferences.core.edit | ||||
| import androidx.datastore.preferences.core.stringPreferencesKey | ||||
| import androidx.lifecycle.AndroidViewModel | ||||
| import androidx.core.net.toUri | ||||
| import androidx.lifecycle.ViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import com.acitelight.aether.AetherApp | ||||
| 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.AuthManager | ||||
| import com.acitelight.aether.service.MediaManager | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import com.acitelight.aether.service.SettingsDataStoreManager | ||||
| 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.map | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.withContext | ||||
| import javax.inject.Inject | ||||
|  | ||||
| class MeScreenViewModel(application: Application) : AndroidViewModel(application) { | ||||
|     private val dataStore = application.dataStore | ||||
|     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") | ||||
| @HiltViewModel | ||||
| class MeScreenViewModel @Inject constructor( | ||||
|     private val settingsDataStoreManager: SettingsDataStoreManager, | ||||
|     @ApplicationContext private val context: Context, | ||||
|     val mediaManager: MediaManager, | ||||
|     private val apiClient: ApiClient, | ||||
|     private val authManager: AuthManager | ||||
| ) : ViewModel() { | ||||
|  | ||||
|     val userNameFlow: Flow<String> = dataStore.data.map { preferences -> | ||||
|         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 username = mutableStateOf("") | ||||
|     val privateKey = mutableStateOf("") | ||||
|     val url = mutableStateOf(""); | ||||
|     val url = mutableStateOf("") | ||||
|     val cert = mutableStateOf("") | ||||
|  | ||||
|     val uss = settingsDataStoreManager.useSelfSignedFlow | ||||
|  | ||||
|     init { | ||||
|         viewModelScope.launch { | ||||
|             username.value = userNameFlow.first() | ||||
|             privateKey.value = if (privateKeyFlow.first() == "") "" else "******" | ||||
|             url.value = urlFlow.first() | ||||
|             cert.value = certFlow.first() | ||||
|             username.value = settingsDataStoreManager.userNameFlow.first() | ||||
|             privateKey.value = if (settingsDataStoreManager.privateKeyFlow.first() == "") "" else "******" | ||||
|             url.value = settingsDataStoreManager.urlFlow.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 { | ||||
|             dataStore.edit { preferences -> | ||||
|                 preferences[URL_KEY] = u | ||||
|                 preferences[CERT_KEY] = c | ||||
|             } | ||||
|             settingsDataStoreManager.saveUrl(u) | ||||
|             settingsDataStoreManager.saveCert(c) | ||||
|  | ||||
|             Global.loggedIn = false | ||||
|  | ||||
|             val us = userNameFlow.first() | ||||
|             val u = urlFlow.first() | ||||
|             val c = certFlow.first() | ||||
|             val p = privateKeyFlow.first() | ||||
|             val us = settingsDataStoreManager.userNameFlow.first() | ||||
|             val p = settingsDataStoreManager.privateKeyFlow.first() | ||||
|  | ||||
|             if (u == "" || c == "" || p == "") return@launch | ||||
|             if (u == "" || p == "" || us == "") return@launch | ||||
|  | ||||
|             try { | ||||
|                 ApiClient.apply(u, c) | ||||
|                 MediaManager.token = AuthManager.fetchToken( | ||||
|                 val usedUrl = apiClient.apply(context, u, if(uss.first()) c else "") | ||||
|                 (context as AetherApp).abyssService?.proxy?.config(apiClient.getBase().toUri().host!!, 4096) | ||||
|                 context.abyssService?.downloader?.init() | ||||
|                 authManager.fetchToken( | ||||
|                     us, | ||||
|                     p | ||||
|                 )!! | ||||
|  | ||||
|                 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) { | ||||
|                 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 { | ||||
|             dataStore.edit { preferences -> | ||||
|                 preferences[USER_NAME_KEY] = u | ||||
|                 preferences[PRIVATE_KEY] = p | ||||
|             } | ||||
|             settingsDataStoreManager.saveUserName(u) | ||||
|             settingsDataStoreManager.savePrivateKey(p) | ||||
|  | ||||
|             privateKey.value = "******" | ||||
|  | ||||
|             Global.loggedIn = false | ||||
|  | ||||
|             val u = userNameFlow.first() | ||||
|             val p = privateKeyFlow.first() | ||||
|             val u = settingsDataStoreManager.userNameFlow.first() | ||||
|             val p = settingsDataStoreManager.privateKeyFlow.first() | ||||
|             val ur = settingsDataStoreManager.urlFlow.first() | ||||
|  | ||||
|             if (u == "" || p == "") return@launch | ||||
|             if (u == "" || p == "" || ur == "") return@launch | ||||
|  | ||||
|             try { | ||||
|                 MediaManager.token = AuthManager.fetchToken( | ||||
|                 authManager.fetchToken( | ||||
|                     u, | ||||
|                     p | ||||
|                 )!! | ||||
|  | ||||
|                 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() | ||||
|             } catch (e: Exception) { | ||||
|                 print(e.message) | ||||
|   | ||||
| @@ -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,105 +1,360 @@ | ||||
| package com.acitelight.aether.viewModel | ||||
|  | ||||
| import android.content.Context | ||||
| import android.net.Uri | ||||
| import android.widget.Toast | ||||
| import androidx.annotation.OptIn | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.State | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableFloatStateOf | ||||
| import androidx.compose.runtime.mutableIntStateOf | ||||
| import androidx.compose.runtime.mutableLongStateOf | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.core.net.toUri | ||||
| import androidx.lifecycle.ViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import androidx.media3.common.MediaItem | ||||
| import androidx.media3.common.PlaybackException | ||||
| import androidx.media3.common.Player | ||||
| import androidx.media3.common.Player.STATE_ENDED | ||||
| 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.datasource.DefaultDataSource | ||||
| import androidx.media3.datasource.okhttp.OkHttpDataSource | ||||
| import androidx.media3.exoplayer.ExoPlayer | ||||
| import com.acitelight.aether.Global | ||||
| import androidx.media3.exoplayer.source.DefaultMediaSourceFactory | ||||
| import androidx.media3.exoplayer.trackselection.DefaultTrackSelector | ||||
| import coil3.ImageLoader | ||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||
| import com.acitelight.aether.model.KeyImage | ||||
| import com.acitelight.aether.model.Video | ||||
| import com.acitelight.aether.model.VideoQueryIndex | ||||
| 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.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.Dispatchers | ||||
| import kotlinx.coroutines.delay | ||||
| import kotlinx.coroutines.flow.first | ||||
| 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 isPlaying by  mutableStateOf(true) | ||||
|     var isPlaying by mutableStateOf(true) | ||||
|     var playProcess by mutableFloatStateOf(0.0f) | ||||
|     var planeVisibility by mutableStateOf(true) | ||||
|     var isLongPressing by mutableStateOf(false) | ||||
|     var dragging by mutableStateOf(false) | ||||
|  | ||||
|     var thumbUp by mutableIntStateOf(0) | ||||
|     var thumbDown by mutableIntStateOf(0) | ||||
|     var star by mutableStateOf(false) | ||||
|  | ||||
|     private var _init: Boolean = false; | ||||
|     // -1 : Not dragging | ||||
|     // 0  : Seek | ||||
|     // 1  : Volume | ||||
|     // 2  : Brightness | ||||
|     var draggingPurpose by mutableIntStateOf(-1) | ||||
|     var locked by mutableStateOf(false) | ||||
|     private var _init: Boolean = false | ||||
|     var startPlaying by mutableStateOf(false) | ||||
|     var renderedFirst = false | ||||
|     var video: Video? = null | ||||
|     var videos: List<Video> = listOf() | ||||
|  | ||||
|     @Composable | ||||
|     fun Init(videoId: String) | ||||
|     { | ||||
|         if(_init) return; | ||||
|         val context = LocalContext.current | ||||
|         val v = videoId.hexToString() | ||||
|     private val httpDataSourceFactory = OkHttpDataSource.Factory(apiClient.getClient()) | ||||
|     private val defaultDataSourceFactory by lazy { | ||||
|         DefaultDataSource.Factory( | ||||
|             context, | ||||
|             httpDataSourceFactory | ||||
|         ) | ||||
|     } | ||||
|  | ||||
|         remember { | ||||
|             viewModelScope.launch { | ||||
|                 video = MediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!! | ||||
|                 RecentManager.Push(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1])) | ||||
|                 _player = ExoPlayer.Builder(context).build().apply { | ||||
|                     val url = video?.getVideo() ?: "" | ||||
|                     val mediaItem = MediaItem.fromUri(url) | ||||
|                     setMediaItem(mediaItem) | ||||
|                     prepare() | ||||
|                     playWhenReady = true | ||||
|     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>()) | ||||
|  | ||||
|                     addListener(object : Player.Listener { | ||||
|                         override fun onPlaybackStateChanged(playbackState: Int) { | ||||
|                             if (playbackState == STATE_READY) { | ||||
|                                 startPlaying = true | ||||
|                             } | ||||
|     @OptIn(UnstableApi::class) | ||||
|     fun init(videoId: String) { | ||||
|         if (_init) | ||||
|             return | ||||
|         _init = true | ||||
|  | ||||
|         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 { | ||||
|                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||
|             } | ||||
|             .build() | ||||
|  | ||||
|         viewModelScope.launch { | ||||
|             videos = mediaManager.queryVideoBulk(vs.first()[0], vs.map { it[1] })!! | ||||
|  | ||||
|             val ii = database.userDao().getAll().first() | ||||
|             val ix = ii.filter { it.id in videos.map{ m -> m.id } }.maxByOrNull { it.time } | ||||
|  | ||||
|             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() { | ||||
|                             super.onRenderedFirstFrame() | ||||
|                             renderedFirst = true | ||||
|                         if (code == 200) { | ||||
|                             return@withContext trimmed.toUri() | ||||
|                         } | ||||
|                     }) | ||||
|  | ||||
|                         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) | ||||
|     fun startListen() | ||||
|     { | ||||
|     fun startListen() { | ||||
|         CoroutineScope(Dispatchers.Main).launch { | ||||
|             while (_player?.isReleased != true) { | ||||
|                 val __player = _player!!; | ||||
|                 playProcess = __player.currentPosition.toFloat() / __player.duration.toFloat() | ||||
|             while (_init) { | ||||
|                 player?.let { playProcess = it.currentPosition.toFloat() / it.duration.toFloat() } | ||||
|                 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() { | ||||
|         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,67 +1,130 @@ | ||||
| package com.acitelight.aether.viewModel | ||||
|  | ||||
| import android.app.Application | ||||
| import android.content.Context | ||||
| import androidx.compose.runtime.State | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableIntStateOf | ||||
| import androidx.datastore.preferences.core.stringPreferencesKey | ||||
| import androidx.lifecycle.AndroidViewModel | ||||
| import androidx.compose.runtime.mutableStateListOf | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.lifecycle.ViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import coil3.ImageLoader | ||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||
| 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.AuthManager | ||||
| import com.acitelight.aether.service.FetchManager | ||||
| import com.acitelight.aether.service.MediaManager | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| 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.map | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.serialization.json.Json | ||||
| import java.io.File | ||||
| import javax.inject.Inject | ||||
|  | ||||
| class VideoScreenViewModel(application: Application) : AndroidViewModel(application) | ||||
| { | ||||
|     private val dataStore = application.dataStore | ||||
|     private val USER_NAME_KEY = stringPreferencesKey("user_name") | ||||
|     private val PRIVATE_KEY   = stringPreferencesKey("private_key") | ||||
|  | ||||
|     val userNameFlow: Flow<String> = dataStore.data.map { preferences -> | ||||
|         preferences[USER_NAME_KEY] ?: "" | ||||
|     } | ||||
|  | ||||
|     val privateKeyFlow: Flow<String> = dataStore.data.map {  preferences -> | ||||
|         preferences[PRIVATE_KEY] ?: "" | ||||
|     } | ||||
|  | ||||
| @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) | ||||
|     val tabIndex: State<Int> = _tabIndex | ||||
|  | ||||
|     private val _videos = MutableStateFlow<List<Video>>(emptyList()) | ||||
|     val videos: StateFlow<List<Video>> = _videos | ||||
|     private val _klasses = MutableStateFlow<List<String>>(emptyList()) | ||||
|     val klasses: StateFlow<List<String>> = _klasses; | ||||
|     var imageLoader: ImageLoader? = null | ||||
|     var menuVisibility = mutableStateOf(false) | ||||
|     var searchFilter = mutableStateOf("") | ||||
|     var doneInit = mutableStateOf(false) | ||||
|  | ||||
|     suspend fun init() { | ||||
|         _klasses.value = MediaManager.listVideoKlasses() | ||||
|         val p = MediaManager.listVideos(_klasses.value.first()) | ||||
|         _videos.value = p | ||||
|         fetchManager.configured.filter { it }.first() | ||||
|  | ||||
|         if (Global.loggedIn) { | ||||
|             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) | ||||
|         } | ||||
|  | ||||
|         doneInit.value = true | ||||
|     } | ||||
|  | ||||
|     fun setTabIndex(index: Int) | ||||
|     { | ||||
|     fun setTabIndex(index: Int) { | ||||
|         viewModelScope.launch() | ||||
|         { | ||||
|             _tabIndex.intValue = index; | ||||
|             val p = MediaManager.listVideos(_klasses.value[index]) | ||||
|             _videos.value = p | ||||
|             _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 }) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun download(video: Video) { | ||||
|         fetchManager.startVideoDownload(video) | ||||
|     } | ||||
|  | ||||
|     init { | ||||
|         viewModelScope.launch { | ||||
|         imageLoader = ImageLoader.Builder(context) | ||||
|             .components { | ||||
|                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||
|             } | ||||
|             .build() | ||||
|  | ||||
|         viewModelScope.launch(Dispatchers.IO) { | ||||
|             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.kotlin.android) 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] | ||||
| agp = "8.12.1" | ||||
| accompanistNavigationAnimation = "0.37.3" | ||||
| agp = "8.13.0" | ||||
| ariaCompiler = "latest" | ||||
| bcprovJdk15on = "1.70" | ||||
| bcprovJdk18on = "1.81" | ||||
| coilCompose = "3.3.0" | ||||
| @@ -7,29 +9,42 @@ coilNetworkOkhttp = "3.3.0" | ||||
| converterGson = "3.0.0" | ||||
| datastorePreferences = "1.1.7" | ||||
| exoplayerplus = "0.2.0" | ||||
| gson = "2.13.1" | ||||
| kotlin = "2.2.10" | ||||
| fetch2 = "3.4.1" | ||||
| fetch2okhttp = "3.4.1" | ||||
| gson = "2.13.2" | ||||
| kotlin = "2.2.20" | ||||
| coreKtx = "1.17.0" | ||||
| junit = "4.13.2" | ||||
| junitVersion = "1.3.0" | ||||
| espressoCore = "3.7.0" | ||||
| kotlinxSerializationJson = "1.9.0" | ||||
| kotlinxSerializationJsonVersion = "1.9.0" | ||||
| lifecycleRuntimeKtx = "2.9.2" | ||||
| activityCompose = "1.10.1" | ||||
| composeBom = "2025.08.00" | ||||
| lifecycleRuntimeKtx = "2.9.4" | ||||
| activityCompose = "1.11.0" | ||||
| composeBom = "2025.09.01" | ||||
| media3Common = "1.8.0" | ||||
| media3Exoplayer = "1.8.0" | ||||
| media3ExoplayerFfmpeg = "1.8.0" | ||||
| media3Ui = "1.8.0" | ||||
| navigationCompose = "2.9.3" | ||||
| navigationCompose = "2.9.5" | ||||
| okhttp = "5.1.0" | ||||
| persistentcookiejar = "1.0.1" | ||||
| repo = "Tag" | ||||
| retrofit = "3.0.0" | ||||
| retrofit2KotlinxSerializationConverter = "1.0.0" | ||||
| retrofitVersion = "3.0.0" | ||||
| tinkAndroid = "1.18.0" | ||||
| tweetnaclJava = "1.0.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] | ||||
| 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-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } | ||||
| androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } | ||||
| @@ -37,11 +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-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" } | ||||
| 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-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprovJdk18on" } | ||||
| 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" } | ||||
| 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" } | ||||
| junit = { group = "junit", name = "junit", version.ref = "junit" } | ||||
| androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } | ||||
| @@ -57,16 +77,23 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man | ||||
| androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } | ||||
| androidx-material3 = { group = "androidx.compose.material3", name = "material3" } | ||||
| kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } | ||||
| kotlinx-serialization-json-v163 = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJsonVersion" } | ||||
| 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-v2110 = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" } | ||||
| retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" } | ||||
| tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tinkAndroid" } | ||||
| tweetnacl-java = { module = "com.github.InstantWebP2P:tweetnacl-java", version.ref = "tweetnaclJava" } | ||||
| 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] | ||||
| android-application = { id = "com.android.application", version.ref = "agp" } | ||||
| kotlin-android = { id = "org.jetbrains.kotlin.android", 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 { | ||||
|         google() | ||||
|         mavenCentral() | ||||
|         maven { | ||||
|             url = uri("https://jitpack.io") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||