Compare commits
	
		
			49 Commits
		
	
	
		
			dev-bug2
			...
			756c2ea9f8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 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 | ||
|   | 01246e89ba | ||
|   | b74ca98bf9 | ||
|   | ded0386419 | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -15,7 +15,7 @@ captures/ | |||||||
| *.aab | *.aab | ||||||
| *.apk | *.apk | ||||||
| output-metadata.json | output-metadata.json | ||||||
|  | release/ | ||||||
| # IntelliJ | # IntelliJ | ||||||
| *.iml | *.iml | ||||||
| .idea/ | .idea/ | ||||||
|   | |||||||
							
								
								
									
										17
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -6,12 +6,9 @@ | |||||||
|  |  | ||||||
| _🚀This is the client of the multimedia server Abyss, which can also be extended to other purposes🚀_ | _🚀This is the client of the multimedia server Abyss, which can also be extended to other purposes🚀_ | ||||||
|  |  | ||||||
|  | <img src="aether_clip.png" width="25%" alt="Logo"> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
| <br/> |  | ||||||
| <br/> |  | ||||||
| <br/> |  | ||||||
|  |  | ||||||
| ## Development background | ## Development background | ||||||
|  |  | ||||||
| - Operating System: Voidraw OS v1.1 (based on Ubuntu) or any compatible Linux distribution. | - Operating System: Voidraw OS v1.1 (based on Ubuntu) or any compatible Linux distribution. | ||||||
| @@ -26,15 +23,15 @@ _🚀This is the client of the multimedia server Abyss, which can also be extend | |||||||
| - [x] Hide private key after user input | - [x] Hide private key after user input | ||||||
| - [x] Optimize API call logic, do not create crashes | - [x] Optimize API call logic, do not create crashes | ||||||
| - [x] Fix the issue of freezing when entering the client without configuring the private key | - [x] Fix the issue of freezing when entering the client without configuring the private key | ||||||
| - [ ] Replace Android robot icon with custom design | - [x] Replace Android robot icon with custom design | ||||||
| - [ ] Configure server baseURL in client settings | - [x] Configure server baseURL in client settings | ||||||
| - [ ] Implement proper access control for directory queries | - [ ] Implement proper access control for directory queries | ||||||
|  |  | ||||||
| ### Medium Priority | ### Medium Priority | ||||||
| - [ ] Increase minHeight for video playback | - [x] Increase minHeight for video playback | ||||||
| - [ ] Add top bar with title and back button in full-screen mode | - [x] Add top bar with title and back button in full-screen mode | ||||||
| - [ ] Optimize data transfer system | - [x] Optimize data transfer system | ||||||
| - [ ] Improve manga/comic page display | - [x] Improve manga/comic page display | ||||||
|  |  | ||||||
| ### Future | ### Future | ||||||
| - [ ] (Prospective) Implement search functionality | - [ ] (Prospective) Implement search functionality | ||||||
							
								
								
									
										
											BIN
										
									
								
								aether.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 616 KiB | 
							
								
								
									
										
											BIN
										
									
								
								aether_clip.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 348 KiB | 
| @@ -3,6 +3,9 @@ plugins { | |||||||
|     alias(libs.plugins.kotlin.android) |     alias(libs.plugins.kotlin.android) | ||||||
|     alias(libs.plugins.kotlin.compose) |     alias(libs.plugins.kotlin.compose) | ||||||
|     kotlin("plugin.serialization") version "1.9.0" |     kotlin("plugin.serialization") version "1.9.0" | ||||||
|  |  | ||||||
|  |     alias(libs.plugins.ksp) | ||||||
|  |     alias(libs.plugins.hilt.android) | ||||||
| } | } | ||||||
|  |  | ||||||
| android { | android { | ||||||
| @@ -26,14 +29,16 @@ android { | |||||||
|                 getDefaultProguardFile("proguard-android-optimize.txt"), |                 getDefaultProguardFile("proguard-android-optimize.txt"), | ||||||
|                 "proguard-rules.pro" |                 "proguard-rules.pro" | ||||||
|             ) |             ) | ||||||
|  |             signingConfig = signingConfigs.getByName("debug") | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     compileOptions { |     compileOptions { | ||||||
|         sourceCompatibility = JavaVersion.VERSION_11 |         sourceCompatibility = JavaVersion.VERSION_21 | ||||||
|         targetCompatibility = JavaVersion.VERSION_11 |         targetCompatibility = JavaVersion.VERSION_21 | ||||||
|     } |     } | ||||||
|     kotlinOptions { |     kotlinOptions { | ||||||
|         jvmTarget = "11" |         jvmTarget = "21" | ||||||
|  |         freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode") | ||||||
|     } |     } | ||||||
|     buildFeatures { |     buildFeatures { | ||||||
|         compose = true |         compose = true | ||||||
| @@ -41,10 +46,23 @@ android { | |||||||
| } | } | ||||||
|  |  | ||||||
| dependencies { | dependencies { | ||||||
|  |     implementation(libs.fetch2) | ||||||
|  |     implementation(libs.fetch2okhttp) | ||||||
|  |  | ||||||
|  |     implementation(libs.hilt.android) | ||||||
|  |     implementation(libs.hilt.navigation.compose) | ||||||
|  |     implementation(libs.androidx.compose.material.core) | ||||||
|  |     ksp(libs.hilt.android.compiler) | ||||||
|  |  | ||||||
|  |     implementation(libs.androidx.room.runtime) | ||||||
|  |     implementation(libs.androidx.room.ktx) | ||||||
|  |     ksp(libs.androidx.room.compiler) | ||||||
|  |  | ||||||
|     implementation(libs.androidx.datastore.preferences) |     implementation(libs.androidx.datastore.preferences) | ||||||
|     implementation(libs.bcprov.jdk15on) |     implementation(libs.bcprov.jdk15on) | ||||||
|     implementation(libs.converter.gson) |     implementation(libs.converter.gson) | ||||||
|     implementation(libs.gson) |     implementation(libs.gson) | ||||||
|  |  | ||||||
|     implementation(libs.androidx.media3.exoplayer) |     implementation(libs.androidx.media3.exoplayer) | ||||||
|     implementation(libs.androidx.media3.ui) |     implementation(libs.androidx.media3.ui) | ||||||
|     implementation(libs.androidx.media3.common) |     implementation(libs.androidx.media3.common) | ||||||
| @@ -67,6 +85,7 @@ dependencies { | |||||||
|     implementation(libs.androidx.ui.graphics) |     implementation(libs.androidx.ui.graphics) | ||||||
|     implementation(libs.androidx.ui.tooling.preview) |     implementation(libs.androidx.ui.tooling.preview) | ||||||
|     implementation(libs.androidx.material3) |     implementation(libs.androidx.material3) | ||||||
|  |     implementation(libs.androidx.media3.datasource.okhttp) | ||||||
|     testImplementation(libs.junit) |     testImplementation(libs.junit) | ||||||
|     androidTestImplementation(libs.androidx.junit) |     androidTestImplementation(libs.androidx.junit) | ||||||
|     androidTestImplementation(libs.androidx.espresso.core) |     androidTestImplementation(libs.androidx.espresso.core) | ||||||
|   | |||||||
| @@ -12,22 +12,31 @@ | |||||||
|         android:usesCleartextTraffic="true" |         android:usesCleartextTraffic="true" | ||||||
|         android:dataExtractionRules="@xml/data_extraction_rules" |         android:dataExtractionRules="@xml/data_extraction_rules" | ||||||
|         android:fullBackupContent="@xml/backup_rules" |         android:fullBackupContent="@xml/backup_rules" | ||||||
|         android:icon="@mipmap/ic_launcher" |         android:icon="@mipmap/aether" | ||||||
|         android:label="@string/app_name" |         android:label="@string/app_name" | ||||||
|         android:roundIcon="@mipmap/ic_launcher_round" |         android:roundIcon="@mipmap/aether_round" | ||||||
|         android:supportsRtl="true" |         android:supportsRtl="true" | ||||||
|         android:theme="@style/Theme.Aether" |         android:theme="@style/Theme.Aether" | ||||||
|         android:name=".AetherApp"> |         android:name=".AetherApp"> | ||||||
|         <activity |         <activity | ||||||
|             android:name=".MainActivity" |             android:name=".MainActivity" | ||||||
|             android:exported="true" |             android:exported="true" | ||||||
|             android:label="@string/app_name" |  | ||||||
|             android:theme="@style/Theme.Aether"> |             android:theme="@style/Theme.Aether"> | ||||||
|             <intent-filter> |             <intent-filter> | ||||||
|                 <action android:name="android.intent.action.MAIN" /> |                 <action android:name="android.intent.action.MAIN" /> | ||||||
|  |  | ||||||
|                 <category android:name="android.intent.category.LAUNCHER" /> |                 <category android:name="android.intent.category.LAUNCHER" /> | ||||||
|             </intent-filter> |             </intent-filter> | ||||||
|         </activity> |         </activity> | ||||||
|  |  | ||||||
|  |         <activity | ||||||
|  |             android:name=".MainScreenActivity" | ||||||
|  |             android:exported="true" | ||||||
|  |             android:theme="@style/Theme.Aether"> | ||||||
|  |         </activity> | ||||||
|  |  | ||||||
|  |         <service | ||||||
|  |             android:name=".AbyssService" | ||||||
|  |             android:enabled="true" | ||||||
|  |             android:exported="false" /> | ||||||
|     </application> |     </application> | ||||||
| </manifest> | </manifest> | ||||||
							
								
								
									
										
											BIN
										
									
								
								app/src/main/aether-playstore.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 163 KiB | 
							
								
								
									
										49
									
								
								app/src/main/java/com/acitelight/aether/AbyssService.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,49 @@ | |||||||
|  | package com.acitelight.aether | ||||||
|  |  | ||||||
|  | import android.app.Service | ||||||
|  | import android.content.Intent | ||||||
|  | import android.os.Binder | ||||||
|  | import android.os.IBinder | ||||||
|  | import com.acitelight.aether.service.AbyssTunnelProxy | ||||||
|  | import com.acitelight.aether.service.FetchManager | ||||||
|  | import com.acitelight.aether.service.SettingsDataStoreManager | ||||||
|  | import dagger.hilt.android.AndroidEntryPoint | ||||||
|  | import kotlinx.coroutines.CoroutineScope | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.Job | ||||||
|  | import kotlinx.coroutines.flow.MutableStateFlow | ||||||
|  | import kotlinx.coroutines.flow.StateFlow | ||||||
|  | import kotlinx.coroutines.flow.asStateFlow | ||||||
|  | import kotlinx.coroutines.flow.update | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
|  | @AndroidEntryPoint | ||||||
|  | class AbyssService: Service() { | ||||||
|  |     @Inject | ||||||
|  |     lateinit var proxy: AbyssTunnelProxy | ||||||
|  |     @Inject | ||||||
|  |     lateinit var downloader: FetchManager | ||||||
|  |  | ||||||
|  |     private val binder = AbyssServiceBinder() | ||||||
|  |     private val _isInitialized = MutableStateFlow(false) | ||||||
|  |     val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow() | ||||||
|  |     private val serviceScope = CoroutineScope(Dispatchers.IO + Job()) | ||||||
|  |  | ||||||
|  |     inner class AbyssServiceBinder : Binder() { | ||||||
|  |         fun getService(): AbyssService = this@AbyssService | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override fun onBind(intent: Intent?): IBinder { | ||||||
|  |         return binder | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override fun onCreate() { | ||||||
|  |         super.onCreate() | ||||||
|  |  | ||||||
|  |         serviceScope.launch { | ||||||
|  |             _isInitialized.update { true } | ||||||
|  |             proxy.start() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,15 +1,51 @@ | |||||||
| package com.acitelight.aether | package com.acitelight.aether | ||||||
|  |  | ||||||
| import android.app.Application | import android.app.Application | ||||||
|  | import android.content.ComponentName | ||||||
| import android.content.Context | import android.content.Context | ||||||
|  | import android.content.Intent | ||||||
|  | import android.content.ServiceConnection | ||||||
|  | import android.os.IBinder | ||||||
| import androidx.datastore.core.DataStore | import androidx.datastore.core.DataStore | ||||||
| import androidx.datastore.preferences.core.Preferences | import androidx.datastore.preferences.core.Preferences | ||||||
| import androidx.datastore.preferences.preferencesDataStore | import androidx.datastore.preferences.preferencesDataStore | ||||||
|  | import dagger.Module | ||||||
|  | import dagger.Provides | ||||||
|  | import dagger.hilt.InstallIn | ||||||
|  | import dagger.hilt.android.HiltAndroidApp | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import dagger.hilt.components.SingletonComponent | ||||||
|  | import kotlinx.coroutines.flow.StateFlow | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
| val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "configure") | val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "configure") | ||||||
|  |  | ||||||
|  | @HiltAndroidApp | ||||||
| class AetherApp : Application() { | class AetherApp : Application() { | ||||||
|  |     var abyssService: AbyssService? = null | ||||||
|  |     var isServiceBound = false | ||||||
|  |         private set | ||||||
|  |  | ||||||
|  |     val isServiceInitialized: StateFlow<Boolean>? | ||||||
|  |         get() = abyssService?.isInitialized | ||||||
|  |  | ||||||
|  |     private val serviceConnection = object : ServiceConnection { | ||||||
|  |         override fun onServiceConnected(name: ComponentName?, service: IBinder?) { | ||||||
|  |             val binder = service as AbyssService.AbyssServiceBinder | ||||||
|  |             abyssService = binder.getService() | ||||||
|  |             isServiceBound = true | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onServiceDisconnected(name: ComponentName?) { | ||||||
|  |             isServiceBound = false | ||||||
|  |             abyssService = null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     override fun onCreate() { |     override fun onCreate() { | ||||||
|         super.onCreate() |         super.onCreate() | ||||||
|  |  | ||||||
|  |         val intent = Intent(this, AbyssService::class.java) | ||||||
|  |         bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -8,4 +8,17 @@ import com.acitelight.aether.model.Video | |||||||
| object Global { | object Global { | ||||||
|     var loggedIn by mutableStateOf(false) |     var loggedIn by mutableStateOf(false) | ||||||
|     var sameClassVideos: List<Video>? = null |     var sameClassVideos: List<Video>? = null | ||||||
|  |     private set | ||||||
|  |  | ||||||
|  |     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,23 +1,34 @@ | |||||||
| package com.acitelight.aether | package com.acitelight.aether | ||||||
|  |  | ||||||
| import android.app.Activity | import android.app.Activity | ||||||
|  | import android.content.Intent | ||||||
| import androidx.compose.material.icons.Icons | import androidx.compose.material.icons.Icons | ||||||
| import android.graphics.drawable.Icon | import android.graphics.drawable.Icon | ||||||
| import android.net.http.SslCertificate.saveState | import android.net.http.SslCertificate.saveState | ||||||
| import android.os.Bundle | import android.os.Bundle | ||||||
|  | import android.view.WindowManager | ||||||
| import androidx.activity.ComponentActivity | import androidx.activity.ComponentActivity | ||||||
| import androidx.activity.compose.setContent | import androidx.activity.compose.setContent | ||||||
| import androidx.activity.enableEdgeToEdge | import androidx.activity.enableEdgeToEdge | ||||||
| import androidx.compose.animation.AnimatedVisibility | import androidx.compose.animation.AnimatedVisibility | ||||||
| import androidx.compose.animation.slideInVertically | import androidx.compose.animation.slideInVertically | ||||||
| import androidx.compose.animation.slideOutVertically | import androidx.compose.animation.slideOutVertically | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.isSystemInDarkTheme | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
| import androidx.compose.foundation.layout.fillMaxSize | import androidx.compose.foundation.layout.fillMaxSize | ||||||
| import androidx.compose.foundation.layout.height | import androidx.compose.foundation.layout.height | ||||||
| import androidx.compose.foundation.layout.heightIn | import androidx.compose.foundation.layout.heightIn | ||||||
| import androidx.compose.foundation.layout.padding | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
| import androidx.compose.material.icons.automirrored.filled.CompareArrows | import androidx.compose.material.icons.automirrored.filled.CompareArrows | ||||||
| import androidx.compose.material.icons.filled.* | import androidx.compose.material.icons.filled.* | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardColors | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
| import androidx.compose.material3.Icon | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.NavigationBar | import androidx.compose.material3.NavigationBar | ||||||
| import androidx.compose.material3.NavigationBarItem | import androidx.compose.material3.NavigationBarItem | ||||||
| import androidx.compose.material3.Scaffold | import androidx.compose.material3.Scaffold | ||||||
| @@ -30,6 +41,7 @@ import androidx.compose.runtime.mutableStateOf | |||||||
| import androidx.compose.runtime.remember | import androidx.compose.runtime.remember | ||||||
| import androidx.compose.runtime.setValue | import androidx.compose.runtime.setValue | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
| import androidx.compose.ui.graphics.vector.ImageVector | import androidx.compose.ui.graphics.vector.ImageVector | ||||||
| import androidx.compose.ui.platform.LocalView | import androidx.compose.ui.platform.LocalView | ||||||
| import androidx.compose.ui.res.painterResource | import androidx.compose.ui.res.painterResource | ||||||
| @@ -38,6 +50,7 @@ import androidx.compose.ui.unit.dp | |||||||
| import androidx.core.view.WindowCompat | import androidx.core.view.WindowCompat | ||||||
| import androidx.core.view.WindowInsetsCompat | import androidx.core.view.WindowInsetsCompat | ||||||
| import androidx.core.view.WindowInsetsControllerCompat | import androidx.core.view.WindowInsetsControllerCompat | ||||||
|  | import androidx.lifecycle.lifecycleScope | ||||||
| import androidx.navigation.NavController | import androidx.navigation.NavController | ||||||
| import androidx.navigation.NavGraph.Companion.findStartDestination | import androidx.navigation.NavGraph.Companion.findStartDestination | ||||||
| import androidx.navigation.NavType | import androidx.navigation.NavType | ||||||
| @@ -47,15 +60,46 @@ import androidx.navigation.compose.composable | |||||||
| import androidx.navigation.compose.currentBackStackEntryAsState | import androidx.navigation.compose.currentBackStackEntryAsState | ||||||
| import androidx.navigation.compose.rememberNavController | import androidx.navigation.compose.rememberNavController | ||||||
| import androidx.navigation.navArgument | import androidx.navigation.navArgument | ||||||
|  | import com.acitelight.aether.view.ComicGridView | ||||||
|  | import com.acitelight.aether.view.ComicPageView | ||||||
| import com.acitelight.aether.view.ComicScreen | import com.acitelight.aether.view.ComicScreen | ||||||
| import com.acitelight.aether.view.HomeScreen | import com.acitelight.aether.view.HomeScreen | ||||||
| import com.acitelight.aether.view.MeScreen | import com.acitelight.aether.view.MeScreen | ||||||
|  | import com.acitelight.aether.view.TransmissionScreen | ||||||
| import com.acitelight.aether.view.VideoPlayer | import com.acitelight.aether.view.VideoPlayer | ||||||
| import com.acitelight.aether.view.VideoScreen | import com.acitelight.aether.view.VideoScreen | ||||||
|  | import dagger.hilt.android.AndroidEntryPoint | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.flow.filter | ||||||
|  | import kotlinx.coroutines.flow.first | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  | import okhttp3.OkHttpClient | ||||||
|  | import okhttp3.Request | ||||||
|  |  | ||||||
|  | @AndroidEntryPoint | ||||||
| class MainActivity : ComponentActivity() { | class MainActivity : ComponentActivity() { | ||||||
|     override fun onCreate(savedInstanceState: Bundle?) { |     override fun onCreate(savedInstanceState: Bundle?) { | ||||||
|         super.onCreate(savedInstanceState) |         super.onCreate(savedInstanceState) | ||||||
|  |  | ||||||
|  |         val app = application as AetherApp | ||||||
|  |  | ||||||
|  |         lifecycleScope.launch { | ||||||
|  |             app.isServiceInitialized?.filter { it }?.first() | ||||||
|  |             val intent = Intent(this@MainActivity, MainScreenActivity::class.java) | ||||||
|  |             startActivity(intent) | ||||||
|  |             finish() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @AndroidEntryPoint | ||||||
|  | class MainScreenActivity : ComponentActivity() { | ||||||
|  |     override fun onCreate(savedInstanceState: Bundle?) { | ||||||
|  |         super.onCreate(savedInstanceState) | ||||||
|  |         window.attributes = window.attributes.apply { | ||||||
|  |             screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE | ||||||
|  |         } | ||||||
|         enableEdgeToEdge() |         enableEdgeToEdge() | ||||||
|         WindowCompat.setDecorFitsSystemWindows(window, false) |         WindowCompat.setDecorFitsSystemWindows(window, false) | ||||||
|         setContent { |         setContent { | ||||||
| @@ -94,6 +138,8 @@ fun AppNavigation() { | |||||||
|  |  | ||||||
|     val hideBottomBarRoutes = listOf( |     val hideBottomBarRoutes = listOf( | ||||||
|         Screen.VideoPlayer.route, |         Screen.VideoPlayer.route, | ||||||
|  |         Screen.ComicGrid.route, | ||||||
|  |         Screen.ComicPage.route | ||||||
|     ) |     ) | ||||||
|     val shouldShowBottomBar = currentRoute !in hideBottomBarRoutes |     val shouldShowBottomBar = currentRoute !in hideBottomBarRoutes | ||||||
|  |  | ||||||
| @@ -112,21 +158,27 @@ fun AppNavigation() { | |||||||
|     ) { innerPadding -> |     ) { innerPadding -> | ||||||
|         NavHost( |         NavHost( | ||||||
|             navController = navController, |             navController = navController, | ||||||
|             startDestination = Screen.Home.route, |             startDestination = Screen.Me.route, | ||||||
|             modifier = if(shouldShowBottomBar)Modifier.padding(innerPadding) else Modifier.padding(0.dp) |             modifier = if(shouldShowBottomBar)Modifier.padding(innerPadding) else Modifier.padding(0.dp) | ||||||
|         ) { |         ) { | ||||||
|             composable(Screen.Home.route) { |             composable(Screen.Home.route) { | ||||||
|                 HomeScreen(navController = navController) |                 CardPage(title = "Home") { | ||||||
|  |                     HomeScreen(navController = navController) | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|             composable(Screen.Video.route) { |             composable(Screen.Video.route) { | ||||||
|                 VideoScreen(navController = navController) |                 VideoScreen(navController = navController) | ||||||
|             } |             } | ||||||
|             composable(Screen.Comic.route) { |             composable(Screen.Comic.route) { | ||||||
|                 ComicScreen() |                 CardPage(title = "Comic") { | ||||||
|  |                     ComicScreen(navController = navController) | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             composable(Screen.Transmission.route) { |             composable(Screen.Transmission.route) { | ||||||
|                 // ComicScreen() |                 CardPage(title = "Tasks") { | ||||||
|  |                     TransmissionScreen(navigator = navController) | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|             composable(Screen.Me.route) { |             composable(Screen.Me.route) { | ||||||
|                 MeScreen(); |                 MeScreen(); | ||||||
| @@ -142,6 +194,30 @@ fun AppNavigation() { | |||||||
|                     VideoPlayer(videoId = videoId, navController = navController) |                     VideoPlayer(videoId = videoId, navController = navController) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|  |             composable( | ||||||
|  |                 route = Screen.ComicGrid.route, | ||||||
|  |                 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, | ||||||
|  |                 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) | ||||||
|  |                     ToggleFullScreen(true) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -158,7 +234,7 @@ fun BottomNavigationBar(navController: NavController) { | |||||||
|         Screen.Transmission, |         Screen.Transmission, | ||||||
|         Screen.Me |         Screen.Me | ||||||
|     ) else  listOf( |     ) else  listOf( | ||||||
|         Screen.Home, |         Screen.Video, | ||||||
|         Screen.Transmission, |         Screen.Transmission, | ||||||
|         Screen.Me |         Screen.Me | ||||||
|     ) |     ) | ||||||
| @@ -183,6 +259,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) { | sealed class Screen(val route: String, val icon: ImageVector, val title: String) { | ||||||
|     data object Home : Screen("home_route", Icons.Filled.Home, "Home") |     data object Home : Screen("home_route", Icons.Filled.Home, "Home") | ||||||
|     data object Video : Screen("video_route", Icons.Filled.VideoLibrary, "Video") |     data object Video : Screen("video_route", Icons.Filled.VideoLibrary, "Video") | ||||||
| @@ -191,4 +298,6 @@ sealed class Screen(val route: String, val icon: ImageVector, val title: String) | |||||||
|         Icons.AutoMirrored.Filled.CompareArrows, "Transmission") |         Icons.AutoMirrored.Filled.CompareArrows, "Transmission") | ||||||
|     data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me") |     data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me") | ||||||
|     data object VideoPlayer : Screen("video_player_route/{videoId}", Icons.Filled.PlayArrow, "VideoPlayer") |     data object VideoPlayer : Screen("video_player_route/{videoId}", Icons.Filled.PlayArrow, "VideoPlayer") | ||||||
|  |     data object ComicGrid : Screen("comic_grid_route/{comicId}", Icons.Filled.PlayArrow, "ComicGrid") | ||||||
|  |     data object ComicPage : Screen("comic_page_route/{comicId}/{page}", Icons.Filled.PlayArrow, "ComicPage") | ||||||
| } | } | ||||||
| @@ -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,8 +1,69 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
| data class Comic( | import com.acitelight.aether.service.ApiClient | ||||||
|     val comic_name: String, |  | ||||||
|     val page_count: Int, | class Comic( | ||||||
|     val bookmarks: List<BookMark>, |     val comic: ComicResponse, | ||||||
|     val pages: List<String> |     val id: String, | ||||||
| ) |     val token: String | ||||||
|  | ) | ||||||
|  | { | ||||||
|  |     fun getPage(pageNumber: Int): String | ||||||
|  |     { | ||||||
|  |         return "${ApiClient.getBase()}api/image/$id/${comic.list[pageNumber]}?token=$token" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getPage(pageName: String): String? | ||||||
|  |     { | ||||||
|  |         val v = comic.list.indexOf(pageName) | ||||||
|  |         if(v >= 0) | ||||||
|  |         { | ||||||
|  |             return getPage(v) | ||||||
|  |         } | ||||||
|  |         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: Int = 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,10 @@ | |||||||
|  | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  | ) | ||||||
| @@ -1,5 +1,9 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Serializable | ||||||
| data class Comment( | data class Comment( | ||||||
|     val content: String, |     val content: String, | ||||||
|     val username: String, |     val username: String, | ||||||
|   | |||||||
| @@ -0,0 +1,30 @@ | |||||||
|  | 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 DownloadItemState( | ||||||
|  |     val id: Int, | ||||||
|  |     fileName: String, | ||||||
|  |     filePath: String, | ||||||
|  |     url: String, | ||||||
|  |     progress: Int, | ||||||
|  |     status: Status, | ||||||
|  |     downloadedBytes: Long, | ||||||
|  |     totalBytes: Long, | ||||||
|  |     klass: String, | ||||||
|  |     vid: 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) | ||||||
|  | } | ||||||
| @@ -1,6 +1,7 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
| data class KeyImage( | data class KeyImage( | ||||||
|  |     val name: String, | ||||||
|     val url: String, |     val url: String, | ||||||
|     val key: String |     val key: String | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -1,29 +1,66 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
| import com.acitelight.aether.service.ApiClient | import com.acitelight.aether.service.ApiClient | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
| import java.security.KeyPair | import java.security.KeyPair | ||||||
|  |  | ||||||
| class Video constructor( |  | ||||||
|  | @Serializable | ||||||
|  | class Video( | ||||||
|  |     val isLocal: Boolean, | ||||||
|  |     val localBase: String, | ||||||
|     val klass: String, |     val klass: String, | ||||||
|     val id: String, |     val id: String, | ||||||
|     val token: String, |     val token: String, | ||||||
|     val video: VideoResponse |     val video: VideoResponse | ||||||
|     ){ | ) { | ||||||
|     fun getCover(): String |     fun getCover(): String { | ||||||
|     { |         return if (isLocal) | ||||||
|         return "${ApiClient.base}api/video/$klass/$id/cover?token=$token" |             "$localBase/videos/$klass/$id/cover.jpg" | ||||||
|  |         else | ||||||
|  |             "${ApiClient.getBase()}api/video/$klass/$id/cover?token=$token" | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun getVideo(): String |     fun getVideo(): String { | ||||||
|     { |         return if (isLocal) | ||||||
|         return "${ApiClient.base}api/video/$klass/$id/av?token=$token" |             "$localBase/videos/$klass/$id/video.mp4" | ||||||
|  |         else | ||||||
|  |             "${ApiClient.getBase()}api/video/$klass/$id/av?token=$token" | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun getGallery(): List<KeyImage> |     fun getSubtitle(): String { | ||||||
|     { |         return if (isLocal) | ||||||
|         return video.gallery.map{ |             "$localBase/videos/$klass/$id/subtitle.vtt" | ||||||
|             KeyImage(url = "${ApiClient.base}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it") |         else | ||||||
|  |             "${ApiClient.getBase()}api/video/$klass/$id/subtitle?token=$token" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getGallery(): 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 = "${ApiClient.getBase()}api/video/$klass/$id/gallery/$it?token=$token", | ||||||
|  |                 key = "$klass/$id/gallery/$it" | ||||||
|  |             ) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun toLocal(localBase: String): Video | ||||||
|  |     { | ||||||
|  |         return Video( | ||||||
|  |             isLocal = true, | ||||||
|  |             localBase = localBase, | ||||||
|  |             klass = klass, | ||||||
|  |             id = id, | ||||||
|  |             token = "", | ||||||
|  |             video = video | ||||||
|  |         ) | ||||||
|  |     } | ||||||
| } | } | ||||||
							
								
								
									
										12
									
								
								app/src/main/java/com/acitelight/aether/model/VideoRecord.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,12 @@ | |||||||
|  | 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 | ||||||
|  | ) | ||||||
| @@ -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, | ||||||
|  |                     "videorecord_database" | ||||||
|  |                 ).build() | ||||||
|  |                 INSTANCE = instance | ||||||
|  |                 instance | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,5 +1,8 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  |  | ||||||
|  | @Serializable | ||||||
| data class VideoResponse( | data class VideoResponse( | ||||||
|     val name: String, |     val name: String, | ||||||
|     val duration: Long, |     val duration: Long, | ||||||
|   | |||||||
							
								
								
									
										371
									
								
								app/src/main/java/com/acitelight/aether/service/AbyssStream.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,371 @@ | |||||||
|  | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import com.acitelight.aether.service.AuthManager.db64 | ||||||
|  | import com.acitelight.aether.service.AuthManager.signChallenge | ||||||
|  | import com.acitelight.aether.service.AuthManager.signChallengeByte | ||||||
|  | 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(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 = 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 (e: 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,123 @@ | |||||||
|  | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  |  | ||||||
|  | import android.util.Log | ||||||
|  | import com.acitelight.aether.service.AuthManager.db64 | ||||||
|  | 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 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 (ex: 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(abyssSocket, 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<Unit> { | ||||||
|  |                 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) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,25 +1,248 @@ | |||||||
|  |  | ||||||
| package com.acitelight.aether.service | 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.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||||
|  | import okhttp3.ConnectionSpec | ||||||
|  | import okhttp3.EventListener | ||||||
|  | import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | ||||||
| import okhttp3.MediaType.Companion.toMediaType | import okhttp3.MediaType.Companion.toMediaType | ||||||
|  | import okhttp3.OkHttpClient | ||||||
| import retrofit2.Retrofit | import retrofit2.Retrofit | ||||||
| import retrofit2.converter.gson.GsonConverterFactory | import retrofit2.converter.gson.GsonConverterFactory | ||||||
|  | import java.io.ByteArrayInputStream | ||||||
|  | import java.net.InetAddress | ||||||
|  | import java.net.InetSocketAddress | ||||||
|  | import java.net.Proxy | ||||||
|  | import java.security.KeyStore | ||||||
|  | import java.security.cert.CertificateException | ||||||
|  | import java.security.cert.CertificateFactory | ||||||
|  | import java.security.cert.X509Certificate | ||||||
|  | import javax.net.ssl.SSLContext | ||||||
|  | import javax.net.ssl.TrustManagerFactory | ||||||
|  | import javax.net.ssl.X509TrustManager | ||||||
|  |  | ||||||
|  |  | ||||||
| object ApiClient { | object ApiClient { | ||||||
|     const val base: String = "http://192.168.1.213/" |     fun getBase(): String{ | ||||||
|  |         return replaceAbyssProtocol(base) | ||||||
|  |     } | ||||||
|  |     private var base: String = "" | ||||||
|  |     var domain: String = "" | ||||||
|  |     var cert: String = "" | ||||||
|     private val json = Json { |     private val json = Json { | ||||||
|         ignoreUnknownKeys = true |         ignoreUnknownKeys = true | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private val retrofit = Retrofit.Builder() |     fun replaceAbyssProtocol(uri: String): String { | ||||||
|         .baseUrl(base) |         return uri.replaceFirst("^abyss://".toRegex(), "https://") | ||||||
|         .addConverterFactory(GsonConverterFactory.create()) |     } | ||||||
|         .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) |  | ||||||
|         .build() |  | ||||||
|  |  | ||||||
|     val api: ApiInterface by lazy { |     private val dnsEventListener = object : EventListener() { | ||||||
|         retrofit.create(ApiInterface::class.java) |         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]") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun loadCertificateFromString(pemString: String): X509Certificate { | ||||||
|  |         val certificateFactory = CertificateFactory.getInstance("X.509") | ||||||
|  |         val decodedPem = pemString | ||||||
|  |             .replace("-----BEGIN CERTIFICATE-----", "") | ||||||
|  |             .replace("-----END CERTIFICATE-----", "") | ||||||
|  |             .replace("\\s+".toRegex(), "") | ||||||
|  |  | ||||||
|  |         val decodedBytes = android.util.Base64.decode(decodedPem, android.util.Base64.DEFAULT) | ||||||
|  |  | ||||||
|  |         ByteArrayInputStream(decodedBytes).use { inputStream -> | ||||||
|  |             return certificateFactory.generateCertificate(inputStream) as X509Certificate | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate?): OkHttpClient { | ||||||
|  |         try { | ||||||
|  |             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 { it is X509TrustManager } as X509TrustManager | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             val combinedTm = object : X509TrustManager { | ||||||
|  |                 override fun getAcceptedIssuers(): Array<X509Certificate> { | ||||||
|  |                     return (defaultTm.acceptedIssuers + (customTm?.acceptedIssuers ?: emptyArray())) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 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(combinedTm), null) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             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) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun createOkHttp(): OkHttpClient { | ||||||
|  |         return if (cert == "") | ||||||
|  |             if (base.startsWith("abyss://")) | ||||||
|  |                 OkHttpClient | ||||||
|  |                     .Builder() | ||||||
|  |                     .proxy( | ||||||
|  |                         Proxy( | ||||||
|  |                             Proxy.Type.HTTP, | ||||||
|  |                             InetSocketAddress("::1", 4095) | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                     .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS)) | ||||||
|  |                     .eventListener(dnsEventListener) | ||||||
|  |                     .build() | ||||||
|  |             else | ||||||
|  |                 OkHttpClient | ||||||
|  |                     .Builder() | ||||||
|  |                     .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS)) | ||||||
|  |                     .eventListener(dnsEventListener) | ||||||
|  |                     .build() | ||||||
|  |         else | ||||||
|  |             createOkHttpClientWithDynamicCert(loadCertificateFromString(cert)) | ||||||
|  |  | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun createRetrofit(): Retrofit { | ||||||
|  |         val okHttpClient = createOkHttp() | ||||||
|  |         val b = replaceAbyssProtocol(base) | ||||||
|  |  | ||||||
|  |         return Retrofit.Builder() | ||||||
|  |             .baseUrl(b) | ||||||
|  |             .client(okHttpClient) | ||||||
|  |             .addConverterFactory(GsonConverterFactory.create()) | ||||||
|  |             .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) | ||||||
|  |             .build() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     var api: ApiInterface? = null | ||||||
|  |  | ||||||
|  |     suspend fun apply(context: Context, urls: String, crt: String): String? { | ||||||
|  |         try { | ||||||
|  |             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 = selectedUrl | ||||||
|  |             withContext(Dispatchers.IO) | ||||||
|  |             { | ||||||
|  |                 (context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096) | ||||||
|  |             } | ||||||
|  |             api = createRetrofit().create(ApiInterface::class.java) | ||||||
|  |  | ||||||
|  |             Log.i("Delay Analyze", "Start Abyss Hello") | ||||||
|  |             val h = api!!.hello() | ||||||
|  |             Log.i("Delay Analyze", "Abyss Hello: ${h.string()}") | ||||||
|  |  | ||||||
|  |             return base | ||||||
|  |         } catch (e: 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 (e: Exception) { | ||||||
|  |             false | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,8 +1,10 @@ | |||||||
| package com.acitelight.aether.service | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import com.acitelight.aether.model.BookMark | ||||||
| import com.acitelight.aether.model.ChallengeResponse | import com.acitelight.aether.model.ChallengeResponse | ||||||
| import com.acitelight.aether.model.Comic | import com.acitelight.aether.model.ComicResponse | ||||||
| import com.acitelight.aether.model.VideoResponse | import com.acitelight.aether.model.VideoResponse | ||||||
|  | import okhttp3.Response | ||||||
| import okhttp3.ResponseBody | import okhttp3.ResponseBody | ||||||
| import retrofit2.http.Body | import retrofit2.http.Body | ||||||
| import retrofit2.http.GET | import retrofit2.http.GET | ||||||
| @@ -28,19 +30,23 @@ interface ApiInterface { | |||||||
|         @Query("token") token: String |         @Query("token") token: String | ||||||
|     ): VideoResponse |     ): VideoResponse | ||||||
|  |  | ||||||
|     @GET("api/video/{klass}/{id}/nv") |     @POST("api/video/{klass}/bulkquery") | ||||||
|     @Streaming |     suspend fun queryVideoBulk( | ||||||
|     suspend fun getNailVideo( |  | ||||||
|         @Path("klass") klass: String, |         @Path("klass") klass: String, | ||||||
|         @Path("id") id: String, |         @Body() id: List<String>, | ||||||
|         @Query("token") token: String |         @Query("token") token: String | ||||||
|     ): ResponseBody |     ): List<VideoResponse> | ||||||
|  |  | ||||||
|     @GET("api/image/collections") |     @GET("api/image") | ||||||
|     suspend fun getComicCollections(): List<String> |     suspend fun getComics(@Query("token") token: String): List<String> | ||||||
|     @GET("api/image/meta") |     @GET("api/image/{id}") | ||||||
|     suspend fun queryComicInfo(@Query("collection") collection: String): Comic |     suspend fun queryComicInfo(@Path("id") id: String, @Query("token") token: String): ComicResponse | ||||||
|  |  | ||||||
|  |     @POST("api/image/bulkquery") | ||||||
|  |     suspend fun queryComicInfoBulk(@Body() id: List<String>, @Query("token") token: String): List<ComicResponse> | ||||||
|  |  | ||||||
|  |     @POST("api/image/{id}/bookmark") | ||||||
|  |     suspend fun postBookmark(@Path("id") id: String, @Query("token") token: String, @Body bookmark: BookMark) | ||||||
|  |  | ||||||
|     @GET("api/user/{user}") |     @GET("api/user/{user}") | ||||||
|     suspend fun getChallenge( |     suspend fun getChallenge( | ||||||
| @@ -52,4 +58,7 @@ interface ApiInterface { | |||||||
|         @Path("user") user: String, |         @Path("user") user: String, | ||||||
|         @Body challengeResponse: ChallengeResponse |         @Body challengeResponse: ChallengeResponse | ||||||
|     ): ResponseBody |     ): ResponseBody | ||||||
|  |  | ||||||
|  |     @GET("api/abyss") | ||||||
|  |     suspend fun hello(): ResponseBody | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,23 +1,34 @@ | |||||||
| package com.acitelight.aether.service | package com.acitelight.aether.service | ||||||
|  |  | ||||||
| import android.util.Base64 | import android.util.Base64 | ||||||
|  | import android.util.Log | ||||||
| import com.acitelight.aether.model.ChallengeResponse | import com.acitelight.aether.model.ChallengeResponse | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.runBlocking | import kotlinx.coroutines.runBlocking | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  | import okhttp3.Call | ||||||
|  | import okhttp3.EventListener | ||||||
|  | import okhttp3.Handshake | ||||||
|  | import okhttp3.OkHttpClient | ||||||
|  | import okhttp3.Request | ||||||
| import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters | import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters | ||||||
| import org.bouncycastle.crypto.signers.Ed25519Signer | import org.bouncycastle.crypto.signers.Ed25519Signer | ||||||
|  | import java.io.IOException | ||||||
|  | import java.lang.reflect.Proxy | ||||||
|  | import java.net.InetSocketAddress | ||||||
| import java.security.PrivateKey | import java.security.PrivateKey | ||||||
| import java.security.Signature | import java.security.Signature | ||||||
|  |  | ||||||
| object AuthManager { | object AuthManager { | ||||||
|     suspend fun fetchToken(baseUrl: String, username: String, privateKey: String): String? { |     suspend fun fetchToken(username: String, privateKey: String): String? { | ||||||
|         val api = ApiClient.api |         val api = ApiClient.api | ||||||
|         var challengeBase64 = "" |         var challengeBase64 = "" | ||||||
|  |  | ||||||
|         try{ |         try{ | ||||||
|             challengeBase64 = api.getChallenge(username).string() |             challengeBase64 = api!!.getChallenge(username).string() | ||||||
|         }catch (e: Exception) |         }catch (e: Exception) | ||||||
|         { |         { | ||||||
|             print(e.message) |             return null | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64)) |         val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64)) | ||||||
| @@ -44,4 +55,14 @@ object AuthManager { | |||||||
|         val signature = signer.generateSignature() |         val signature = signer.generateSignature() | ||||||
|         return Base64.encodeToString(signature, Base64.NO_WRAP) |         return Base64.encodeToString(signature, Base64.NO_WRAP) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun signChallengeByte(privateKey: ByteArray, data: ByteArray): ByteArray //64 Byte | ||||||
|  |     { | ||||||
|  |         val privateKeyParams = Ed25519PrivateKeyParameters(privateKey, 0) | ||||||
|  |         val signer = Ed25519Signer() | ||||||
|  |         signer.init(true, privateKeyParams) | ||||||
|  |  | ||||||
|  |         signer.update(data, 0, data.size) | ||||||
|  |         return signer.generateSignature() | ||||||
|  |     } | ||||||
| } | } | ||||||
							
								
								
									
										176
									
								
								app/src/main/java/com/acitelight/aether/service/FetchManager.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,176 @@ | |||||||
|  | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.compose.runtime.mutableStateOf | ||||||
|  | import com.acitelight.aether.Screen | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.service.ApiClient.createOkHttp | ||||||
|  | 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.fetch2.Status | ||||||
|  | import com.tonyodev.fetch2core.Extras | ||||||
|  | import com.tonyodev.fetch2okhttp.OkHttpDownloader | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.flow.MutableStateFlow | ||||||
|  | import kotlinx.coroutines.flow.StateFlow | ||||||
|  | import kotlinx.coroutines.flow.asStateFlow | ||||||
|  | import kotlinx.coroutines.flow.filter | ||||||
|  | import kotlinx.coroutines.flow.first | ||||||
|  | import kotlinx.coroutines.flow.update | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import okhttp3.OkHttpClient | ||||||
|  | import java.io.File | ||||||
|  | import java.io.IOException | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
|  | @Singleton | ||||||
|  | class FetchManager @Inject constructor( | ||||||
|  |     @ApplicationContext private val context: Context | ||||||
|  | ) { | ||||||
|  |     private var fetch: Fetch? = null | ||||||
|  |     private var listener: FetchListener? = null | ||||||
|  |     private var client: OkHttpClient? = null | ||||||
|  |     val configured = MutableStateFlow(false) | ||||||
|  |  | ||||||
|  |     fun init() | ||||||
|  |     { | ||||||
|  |         client = createOkHttp() | ||||||
|  |         val fetchConfiguration = FetchConfiguration.Builder(context) | ||||||
|  |             .setDownloadConcurrentLimit(8) | ||||||
|  |             .setHttpDownloader(OkHttpDownloader(client)) | ||||||
|  |             .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 | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // query downloads | ||||||
|  |     suspend fun getAllDownloads(callback: (List<Download>) -> Unit) { | ||||||
|  |         configured.filter { it }.first() | ||||||
|  |         fetch?.getDownloads { list -> callback(list) } ?: callback(emptyList()) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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 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 getVideosDirectory() { | ||||||
|  |         val appFilesDir = context.getExternalFilesDir(null) | ||||||
|  |         val videosDir = File(appFilesDir, "videos") | ||||||
|  |  | ||||||
|  |         if (!videosDir.exists()) { | ||||||
|  |             val created = videosDir.mkdirs() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun downloadFile( | ||||||
|  |         client: OkHttpClient, | ||||||
|  |         url: String, | ||||||
|  |         destFile: File | ||||||
|  |     ): Result<Unit> = withContext(Dispatchers.IO) { | ||||||
|  |         try { | ||||||
|  |             val request = okhttp3.Request.Builder().url(url).build() | ||||||
|  |             client.newCall(request).execute().use { response -> | ||||||
|  |                 if (!response.isSuccessful) { | ||||||
|  |                     return@withContext Result.failure(IOException("Unexpected code $response")) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 destFile.parentFile?.mkdirs() | ||||||
|  |                 response.body.byteStream().use { input -> | ||||||
|  |                     destFile.outputStream().use { output -> | ||||||
|  |                         input.copyTo(output) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             Result.success(Unit) | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             Result.failure(e) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun startVideoDownload(video: Video) | ||||||
|  |     { | ||||||
|  |         val path = File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/video.mp4") | ||||||
|  |         val request = Request(video.getVideo(), path.path).apply { | ||||||
|  |             extras = Extras(mapOf("name" to video.video.name, "id" to video.id, "class" to video.klass)) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         downloadFile( | ||||||
|  |             client!!, | ||||||
|  |             video.getCover(), | ||||||
|  |             File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/cover.jpg")) | ||||||
|  |  | ||||||
|  |         downloadFile( | ||||||
|  |             client!!, | ||||||
|  |             video.getSubtitle(), | ||||||
|  |             File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/subtitle.vtt")) | ||||||
|  |  | ||||||
|  |         enqueue(request) | ||||||
|  |         File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/summary.json").writeText(Json.encodeToString(video)) | ||||||
|  |  | ||||||
|  |         for(p in video.getGallery()) | ||||||
|  |         { | ||||||
|  |             downloadFile( | ||||||
|  |                 client!!, | ||||||
|  |                 p.url, | ||||||
|  |                 File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/gallery/${p.name}")) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,13 +1,23 @@ | |||||||
| package com.acitelight.aether.service | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import com.acitelight.aether.model.BookMark | ||||||
| import com.acitelight.aether.model.Comic | import com.acitelight.aether.model.Comic | ||||||
|  | import com.acitelight.aether.model.ComicResponse | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import kotlinx.coroutines.Dispatchers | import com.tonyodev.fetch2.Status | ||||||
| import kotlinx.coroutines.withContext | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import java.io.IOException | 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 | ||||||
|  | ) | ||||||
| { | { | ||||||
|     var token: String = "null" |     var token: String = "null" | ||||||
|  |  | ||||||
| @@ -15,7 +25,7 @@ object MediaManager | |||||||
|     { |     { | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             val j = ApiClient.api.getVideoClasses(token) |             val j = ApiClient.api!!.getVideoClasses(token) | ||||||
|             return j.toList() |             return j.toList() | ||||||
|         }catch(e: Exception) |         }catch(e: Exception) | ||||||
|         { |         { | ||||||
| @@ -23,14 +33,13 @@ object MediaManager | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun listVideos(klass: String): List<Video> |     suspend fun queryVideoKlasses(klass: String): List<String> | ||||||
|     { |     { | ||||||
|         try { |         try | ||||||
|             val j = ApiClient.api.queryVideoClasses(klass, token) |         { | ||||||
|             return j.map{ |             val j = ApiClient.api!!.queryVideoClasses(klass, token) | ||||||
|                 queryVideo(klass, it)!! |             return j.toList() | ||||||
|             }.toList() |         }catch(e: Exception) | ||||||
|         }catch (e: Exception) |  | ||||||
|         { |         { | ||||||
|             return listOf() |             return listOf() | ||||||
|         } |         } | ||||||
| @@ -38,22 +47,130 @@ object MediaManager | |||||||
|  |  | ||||||
|     suspend fun queryVideo(klass: String, id: String): Video? |     suspend fun queryVideo(klass: String, id: String): Video? | ||||||
|     { |     { | ||||||
|  |         val downloaded = fetchManager.getAllDownloadsAsync().filter { | ||||||
|  |             it.status == Status.COMPLETED && | ||||||
|  |             it.extras.getString("id", "") == id && | ||||||
|  |             it.extras.getString("class", "") == klass | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if(!downloaded.isEmpty()) | ||||||
|  |         { | ||||||
|  |             val jsonString = File( | ||||||
|  |                 context.getExternalFilesDir(null), | ||||||
|  |                 "videos/$klass/$id/summary.json" | ||||||
|  |             ).readText() | ||||||
|  |             return Json.decodeFromString<Video>(jsonString).toLocal(context.getExternalFilesDir(null)?.path!!) | ||||||
|  |         } | ||||||
|  |  | ||||||
|         try { |         try { | ||||||
|             val j = ApiClient.api.queryVideo(klass, id, token) |             val j = ApiClient.api!!.queryVideo(klass, id, token) | ||||||
|             return Video(klass = klass, id = id, token=token, j) |             return Video(klass = klass, id = id, token=token, isLocal = false, localBase = "", video = j) | ||||||
|         }catch (e: Exception) |         }catch (e: Exception) | ||||||
|         { |         { | ||||||
|             return null |             return null | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun listComics() : List<String> |     suspend fun queryVideoBulk(klass: String, id: List<String>): List<Video>? { | ||||||
|     { |         return try { | ||||||
|         return ApiClient.api.getComicCollections() |             val completedDownloads = fetchManager.getAllDownloadsAsync() | ||||||
|  |                 .filter { it.status == Status.COMPLETED } | ||||||
|  |             val localIds = mutableSetOf<String>() | ||||||
|  |             val remoteIds = mutableListOf<String>() | ||||||
|  |  | ||||||
|  |             for (videoId in id) { | ||||||
|  |                 if (completedDownloads.any { | ||||||
|  |                         it.extras.getString("id", "") == videoId && | ||||||
|  |                                 it.extras.getString("class", "") == klass | ||||||
|  |                     }) { | ||||||
|  |                     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 (e: Exception) { | ||||||
|  |                         null | ||||||
|  |                     } | ||||||
|  |                 } else { | ||||||
|  |                     null | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             val remoteVideos = if (remoteIds.isNotEmpty()) { | ||||||
|  |                 val j = ApiClient.api!!.queryVideoBulk(klass, remoteIds, token) | ||||||
|  |                 j.zip(remoteIds).map { | ||||||
|  |                     Video( | ||||||
|  |                         klass = klass, | ||||||
|  |                         id = it.second, | ||||||
|  |                         token = token, | ||||||
|  |                         isLocal = false, | ||||||
|  |                         localBase = "", | ||||||
|  |                         video = it.first | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 emptyList() | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             localVideos + remoteVideos | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             null | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun queryComicInfo(c: String) : Comic |     suspend fun listComics() : List<String> | ||||||
|     { |     { | ||||||
|         return ApiClient.api.queryComicInfo(c) |         try{ | ||||||
|  |             val j = ApiClient.api!!.getComics(token) | ||||||
|  |             return j | ||||||
|  |         }catch (e: Exception) | ||||||
|  |         { | ||||||
|  |             return listOf() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun queryComicInfoSingle(id: String) : Comic? | ||||||
|  |     { | ||||||
|  |         try{ | ||||||
|  |             val j = ApiClient.api!!.queryComicInfo(id, token) | ||||||
|  |             return Comic(id = id, comic = j, token = token) | ||||||
|  |         }catch (e: Exception) | ||||||
|  |         { | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun queryComicInfoBulk(id: List<String>) : List<Comic>? | ||||||
|  |     { | ||||||
|  |         try{ | ||||||
|  |             val j = ApiClient.api!!.queryComicInfoBulk(id, token) | ||||||
|  |             return j.zip(id).map { Comic(id = it.second, comic = it.first, token = token) } | ||||||
|  |         }catch (e: Exception) | ||||||
|  |         { | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun postBookmark(id: String, bookMark: BookMark): Boolean | ||||||
|  |     { | ||||||
|  |         try{ | ||||||
|  |             val j = ApiClient.api!!.postBookmark(id, token, bookMark) | ||||||
|  |             return true | ||||||
|  |         }catch (e: Exception) | ||||||
|  |         { | ||||||
|  |             return false | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,11 +1,11 @@ | |||||||
| package com.acitelight.aether.service | package com.acitelight.aether.service | ||||||
|  |  | ||||||
| import android.content.Context | 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.Video | ||||||
| import com.acitelight.aether.model.VideoQueryIndex | import com.acitelight.aether.model.VideoQueryIndex | ||||||
| import kotlinx.coroutines.Dispatchers | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.flow.MutableStateFlow |  | ||||||
| import kotlinx.coroutines.flow.StateFlow |  | ||||||
| import kotlinx.coroutines.sync.Mutex | import kotlinx.coroutines.sync.Mutex | ||||||
| import kotlinx.coroutines.sync.withLock | import kotlinx.coroutines.sync.withLock | ||||||
| import kotlinx.coroutines.withContext | import kotlinx.coroutines.withContext | ||||||
| @@ -13,12 +13,17 @@ import kotlinx.serialization.json.* | |||||||
| import java.io.File | import java.io.File | ||||||
| import java.io.FileNotFoundException | import java.io.FileNotFoundException | ||||||
| import java.io.IOException | import java.io.IOException | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
| object RecentManager | @Singleton | ||||||
|  | class RecentManager @Inject constructor( | ||||||
|  |     private val mediaManager: MediaManager | ||||||
|  | ) | ||||||
| { | { | ||||||
|     private val mutex = Mutex() |     private val mutex = Mutex() | ||||||
|  |  | ||||||
|     suspend fun readFile(context: Context, filename: String): String { |     private suspend fun readFile(context: Context, filename: String): String { | ||||||
|         return withContext(Dispatchers.IO) { |         return withContext(Dispatchers.IO) { | ||||||
|             try { |             try { | ||||||
|                 val file = File(context.filesDir, filename) |                 val file = File(context.filesDir, filename) | ||||||
| @@ -32,7 +37,7 @@ object RecentManager | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun writeFile(context: Context, filename: String, content: String) { |     private suspend fun writeFile(context: Context, filename: String, content: String) { | ||||||
|         withContext(Dispatchers.IO) { |         withContext(Dispatchers.IO) { | ||||||
|             try { |             try { | ||||||
|                 val file = File(context.filesDir, filename) |                 val file = File(context.filesDir, filename) | ||||||
| @@ -44,17 +49,97 @@ 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 (e: NoSuchMethodError) { | ||||||
|  |                 for (id in ids) { | ||||||
|  |                     val c = mediaManager.queryComicInfoSingle(id) | ||||||
|  |                     if (c != null) recentComic.add(recentComic.size, c) | ||||||
|  |                 } | ||||||
|  |             } catch (e: 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 c = readFile(context, "recent_comic.json") | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             val o = recentComic.map { it.id }.toMutableList() | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             if (o.contains(comicId)) { | ||||||
|  |                 val index = o.indexOf(comicId) | ||||||
|  |                 recentComic.removeAt(index) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             val comic = mediaManager.queryComicInfoSingle(comicId) | ||||||
|  |             if (comic != null) { | ||||||
|  |                 recentComic.add(0, comic) | ||||||
|  |             } else { | ||||||
|  |                 return | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (recentComic.size > 21) { | ||||||
|  |                 recentComic.removeAt(recentComic.size - 1) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             writeFile(context, "recent_comic.json", Json.encodeToString(recentComic.map { it.id })) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun queryVideo(context: Context): List<VideoQueryIndex> | ||||||
|     { |     { | ||||||
|         val content = readFile(context, "recent.json") |         val content = readFile(context, "recent.json") | ||||||
|         try{ |         try{ | ||||||
|             val r = Json.decodeFromString<List<VideoQueryIndex>>(content) |             val r = Json.decodeFromString<List<VideoQueryIndex>>(content) | ||||||
|  |  | ||||||
|             val vn = r.map{ |             recentVideo.clear() | ||||||
|                 MediaManager.queryVideo(it.klass, it.id) |             val gr = r.groupBy { it.klass } | ||||||
|             }.filter { it != null } |  | ||||||
|  |             for(it in gr) | ||||||
|  |             { | ||||||
|  |                 val v = mediaManager.queryVideoBulk(it.key, it.value.map { it.id }) | ||||||
|  |                 if(v != null) | ||||||
|  |                     for(j in v) | ||||||
|  |                     { | ||||||
|  |                         recentVideo.add(recentVideo.size, j) | ||||||
|  |                     } | ||||||
|  |             } | ||||||
|  |  | ||||||
|             _recent.value = vn.map { it!! } |  | ||||||
|             return r |             return r | ||||||
|         }catch (e: Exception) |         }catch (e: Exception) | ||||||
|         { |         { | ||||||
| @@ -64,36 +149,29 @@ object RecentManager | |||||||
|         return listOf() |         return listOf() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun Push(context: Context, video: VideoQueryIndex) |     suspend fun pushVideo(context: Context, video: VideoQueryIndex) | ||||||
|     { |     { | ||||||
|         mutex.withLock{ |         mutex.withLock{ | ||||||
|             val content = readFile(context, "recent.json") |             val 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)) |             if(o.contains(video)) | ||||||
|             { |             { | ||||||
|                 val temp = o[0] |  | ||||||
|                 val index = o.indexOf(video) |                 val index = o.indexOf(video) | ||||||
|                 o[0] = o[index] |                 val temp = recentVideo[index] | ||||||
|                 o[index] = temp |  | ||||||
|             } |                 recentVideo.removeAt(index) | ||||||
|             else |  | ||||||
|             { |  | ||||||
|                 o.add(0, video) |  | ||||||
|             } |             } | ||||||
|  |             recentVideo.add(0, mediaManager.queryVideo(video.klass, video.id)!!) | ||||||
|  |  | ||||||
|             if(o.size >= 21) |  | ||||||
|                 o.removeAt(o.size - 1) |  | ||||||
|  |  | ||||||
|             val vn = o.map{ |             if(recentVideo.size >= 21) | ||||||
|                 MediaManager.queryVideo(it.klass, it.id) |                 recentVideo.removeAt(o.size - 1) | ||||||
|             }.filter { it != null } |  | ||||||
|             _recent.value = vn.map { it!! } |  | ||||||
|  |  | ||||||
|             writeFile(context, "recent.json", Json.encodeToString(o)) |             writeFile(context, "recent.json", Json.encodeToString(recentVideo.map{ VideoQueryIndex(it.klass, it.id) })) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private val _recent = MutableStateFlow<List<Video>>(emptyList()) |     val recentVideo = mutableStateListOf<Video>() | ||||||
|     val recent: StateFlow<List<Video>> = _recent |     val recentComic = mutableStateListOf<Comic>() | ||||||
| } | } | ||||||
| @@ -0,0 +1,85 @@ | |||||||
|  | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.datastore.core.DataStore | ||||||
|  | import androidx.datastore.preferences.core.Preferences | ||||||
|  | import androidx.datastore.preferences.core.booleanPreferencesKey | ||||||
|  | import androidx.datastore.preferences.core.edit | ||||||
|  | import androidx.datastore.preferences.core.stringPreferencesKey | ||||||
|  | import androidx.datastore.preferences.preferencesDataStore | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.coroutines.flow.Flow | ||||||
|  | import kotlinx.coroutines.flow.map | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
|  | private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") | ||||||
|  |  | ||||||
|  | @Singleton | ||||||
|  | class SettingsDataStoreManager @Inject constructor( | ||||||
|  |     @ApplicationContext private val context: Context | ||||||
|  | ) { | ||||||
|  |     companion object { | ||||||
|  |         val USER_NAME_KEY = stringPreferencesKey("user_name") | ||||||
|  |         val PRIVATE_KEY = stringPreferencesKey("private_key") | ||||||
|  |         val URL_KEY = stringPreferencesKey("url") | ||||||
|  |         val CERT_KEY = stringPreferencesKey("cert") | ||||||
|  |         val USE_SELF_SIGNED_KEY = booleanPreferencesKey("use_self_signed") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val userNameFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||||
|  |         preferences[USER_NAME_KEY] ?: "" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val privateKeyFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||||
|  |         preferences[PRIVATE_KEY] ?: "" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val urlFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||||
|  |         preferences[URL_KEY] ?: "" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val certFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||||
|  |         preferences[CERT_KEY] ?: "" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val useSelfSignedFlow: Flow<Boolean> = context.dataStore.data.map { preferences -> | ||||||
|  |         preferences[USE_SELF_SIGNED_KEY] ?: false | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun saveUserName(name: String) { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences[USER_NAME_KEY] = name | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun savePrivateKey(key: String) { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences[PRIVATE_KEY] = key | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun saveUrl(url: String) { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences[URL_KEY] = url | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun saveCert(cert: String) { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences[CERT_KEY] = cert | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun saveUseSelfSigned(useSelfSigned: Boolean) { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences[USE_SELF_SIGNED_KEY] = useSelfSigned | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun clearAll() { | ||||||
|  |         context.dataStore.edit { preferences -> | ||||||
|  |             preferences.clear() | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,20 @@ | |||||||
|  | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.runtime.mutableStateMapOf | ||||||
|  | import androidx.compose.runtime.snapshots.SnapshotStateList | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
|  | @Singleton | ||||||
|  | class VideoLibrary @Inject constructor( | ||||||
|  |     @ApplicationContext private val context: Context | ||||||
|  | )  { | ||||||
|  |  | ||||||
|  |     var classes = mutableStateListOf<String>() | ||||||
|  |     val classesMap = mutableStateMapOf<String, SnapshotStateList<Video>>() | ||||||
|  |     val updatingMap: MutableMap<Int, Boolean> = mutableMapOf() | ||||||
|  | } | ||||||
| @@ -3,35 +3,135 @@ package com.acitelight.aether.ui.theme | |||||||
| import android.app.Activity | import android.app.Activity | ||||||
| import android.os.Build | import android.os.Build | ||||||
| import androidx.compose.foundation.isSystemInDarkTheme | import androidx.compose.foundation.isSystemInDarkTheme | ||||||
|  | import androidx.compose.material3.ColorScheme | ||||||
| import androidx.compose.material3.MaterialTheme | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.darkColorScheme | import androidx.compose.material3.darkColorScheme | ||||||
| import androidx.compose.material3.dynamicDarkColorScheme | import androidx.compose.material3.dynamicDarkColorScheme | ||||||
| import androidx.compose.material3.dynamicLightColorScheme | import androidx.compose.material3.dynamicLightColorScheme | ||||||
| import androidx.compose.material3.lightColorScheme | import androidx.compose.material3.lightColorScheme | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.graphics.lerp | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.compose.ui.platform.LocalContext | ||||||
|  |  | ||||||
| private val DarkColorScheme = darkColorScheme( | fun generateColorScheme(primaryColor: Color, isDarkMode: Boolean): ColorScheme { | ||||||
|     primary = Purple80, |  | ||||||
|     secondary = PurpleGrey80, |  | ||||||
|     tertiary = Pink80 |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| private val LightColorScheme = lightColorScheme( |     val background = if (isDarkMode) Color(0xFF121212) else Color(0xFFFFFFFF) | ||||||
|     primary = Purple40, |     val surface = if (isDarkMode) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) | ||||||
|     secondary = PurpleGrey40, |  | ||||||
|     tertiary = Pink40 |  | ||||||
|  |  | ||||||
|     /* Other default colors to override |     val surfaceContainer = if (isDarkMode) Color(0xFF232323) else Color(0xFFFDFDFD) | ||||||
|     background = Color(0xFFFFFBFE), |     val surfaceContainerLow = if (isDarkMode) Color(0xFF1A1A1A) else Color(0xFFF5F5F5) | ||||||
|     surface = Color(0xFFFFFBFE), |     val surfaceContainerHigh = if (isDarkMode) Color(0xFF2A2A2A) else Color(0xFFFAFAFA) | ||||||
|     onPrimary = Color.White, |     val surfaceContainerHighest = if (isDarkMode) Color(0xFF333333) else Color(0xFFFFFFFF) | ||||||
|     onSecondary = Color.White, |     val surfaceContainerLowest = if (isDarkMode) Color(0xFF0F0F0F) else Color(0xFFF0F0F0) | ||||||
|     onTertiary = Color.White, |     val surfaceBright = if (isDarkMode) Color(0xFF2C2C2C) else Color(0xFFFFFFFF) | ||||||
|     onBackground = Color(0xFF1C1B1F), |     val surfaceDim = if (isDarkMode) Color(0xFF141414) else Color(0xFFF8F8F8) | ||||||
|     onSurface = Color(0xFF1C1B1F), |  | ||||||
|     */ |     fun tint(surface: Color, factor: Float) = lerp(surface, primaryColor, factor) | ||||||
| ) |  | ||||||
|  |     return if (isDarkMode) { | ||||||
|  |         darkColorScheme( | ||||||
|  |             primary = primaryColor, | ||||||
|  |             onPrimary = Color.White, | ||||||
|  |             primaryContainer = tint(primaryColor, 0.2f), | ||||||
|  |             onPrimaryContainer = Color.White, | ||||||
|  |             inversePrimary = tint(primaryColor, 0.6f), | ||||||
|  |  | ||||||
|  |             secondary = tint(primaryColor, 0.4f), | ||||||
|  |             onSecondary = Color.White, | ||||||
|  |             secondaryContainer = tint(primaryColor, 0.2f), | ||||||
|  |             onSecondaryContainer = Color.White, | ||||||
|  |  | ||||||
|  |             tertiary = tint(primaryColor, 0.5f), | ||||||
|  |             onTertiary = Color.White, | ||||||
|  |             tertiaryContainer = tint(primaryColor, 0.2f), | ||||||
|  |             onTertiaryContainer = Color.White, | ||||||
|  |  | ||||||
|  |             background = background, | ||||||
|  |             onBackground = Color.White, | ||||||
|  |  | ||||||
|  |             surface = surface, | ||||||
|  |             onSurface = Color.White, | ||||||
|  |  | ||||||
|  |             surfaceVariant = tint(surface, 0.1f), | ||||||
|  |             onSurfaceVariant = Color(0xFFE0E0E0), | ||||||
|  |  | ||||||
|  |             surfaceTint = primaryColor, | ||||||
|  |  | ||||||
|  |             inverseSurface = Color.White, | ||||||
|  |             inverseOnSurface = Color(0xFF121212), | ||||||
|  |  | ||||||
|  |             error = Color(0xFFCF6679), | ||||||
|  |             onError = Color.Black, | ||||||
|  |             errorContainer = Color(0xFFB00020), | ||||||
|  |             onErrorContainer = Color.White, | ||||||
|  |  | ||||||
|  |             outline = Color(0xFF757575), | ||||||
|  |             outlineVariant = Color(0xFF494949), | ||||||
|  |             scrim = Color.Black, | ||||||
|  |  | ||||||
|  |             surfaceBright = tint(surfaceBright, 0.1f), | ||||||
|  |             surfaceContainer = tint(surfaceContainer, 0.1f), | ||||||
|  |             surfaceContainerHigh = tint(surfaceContainerHigh, 0.12f), | ||||||
|  |             surfaceContainerHighest = tint(surfaceContainerHighest, 0.15f), | ||||||
|  |             surfaceContainerLow = tint(surfaceContainerLow, 0.08f), | ||||||
|  |             surfaceContainerLowest = tint(surfaceContainerLowest, 0.05f), | ||||||
|  |             surfaceDim = tint(surfaceDim, 0.1f) | ||||||
|  |         ) | ||||||
|  |     } else { | ||||||
|  |         lightColorScheme( | ||||||
|  |             primary = primaryColor, | ||||||
|  |             onPrimary = Color.White, | ||||||
|  |             primaryContainer = tint(primaryColor, 0.3f), | ||||||
|  |             onPrimaryContainer = Color.Black, | ||||||
|  |             inversePrimary = tint(primaryColor, 0.6f), | ||||||
|  |  | ||||||
|  |             secondary = tint(primaryColor, 0.4f), | ||||||
|  |             onSecondary = Color.Black, | ||||||
|  |             secondaryContainer = tint(primaryColor, 0.2f), | ||||||
|  |             onSecondaryContainer = Color.Black, | ||||||
|  |  | ||||||
|  |             tertiary = tint(primaryColor, 0.5f), | ||||||
|  |             onTertiary = Color.Black, | ||||||
|  |             tertiaryContainer = tint(primaryColor, 0.3f), | ||||||
|  |             onTertiaryContainer = Color.Black, | ||||||
|  |  | ||||||
|  |             background = background, | ||||||
|  |             onBackground = Color.Black, | ||||||
|  |  | ||||||
|  |             surface = surface, | ||||||
|  |             onSurface = Color.Black, | ||||||
|  |  | ||||||
|  |             surfaceVariant = tint(surface, 0.1f), | ||||||
|  |             onSurfaceVariant = Color(0xFF49454F), | ||||||
|  |  | ||||||
|  |             surfaceTint = primaryColor, | ||||||
|  |  | ||||||
|  |             inverseSurface = Color(0xFF121212), | ||||||
|  |             inverseOnSurface = Color.White, | ||||||
|  |  | ||||||
|  |             error = Color(0xFFB00020), | ||||||
|  |             onError = Color.White, | ||||||
|  |             errorContainer = Color(0xFFFFDAD6), | ||||||
|  |             onErrorContainer = Color.Black, | ||||||
|  |  | ||||||
|  |             outline = Color(0xFF737373), | ||||||
|  |             outlineVariant = Color(0xFFD0C4C9), | ||||||
|  |             scrim = Color.Black, | ||||||
|  |  | ||||||
|  |             surfaceBright = tint(surfaceBright, 0.1f), | ||||||
|  |             surfaceContainer = tint(surfaceContainer, 0.1f), | ||||||
|  |             surfaceContainerHigh = tint(surfaceContainerHigh, 0.12f), | ||||||
|  |             surfaceContainerHighest = tint(surfaceContainerHighest, 0.15f), | ||||||
|  |             surfaceContainerLow = tint(surfaceContainerLow, 0.08f), | ||||||
|  |             surfaceContainerLowest = tint(surfaceContainerLowest, 0.05f), | ||||||
|  |             surfaceDim = tint(surfaceDim, 0.05f) | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | private val DarkColorScheme = generateColorScheme(Color(0xFF36A0FF), isDarkMode = true) | ||||||
|  | private val LightColorScheme = generateColorScheme(Color(0xFF36A0FF), isDarkMode = false) | ||||||
|  |  | ||||||
| @Composable | @Composable | ||||||
| fun AetherTheme( | fun AetherTheme( | ||||||
| @@ -41,11 +141,6 @@ fun AetherTheme( | |||||||
|     content: @Composable () -> Unit |     content: @Composable () -> Unit | ||||||
| ) { | ) { | ||||||
|     val colorScheme = when { |     val colorScheme = when { | ||||||
|         dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { |  | ||||||
|             val context = LocalContext.current |  | ||||||
|             if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         darkTheme -> DarkColorScheme |         darkTheme -> DarkColorScheme | ||||||
|         else -> LightColorScheme |         else -> LightColorScheme | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										60
									
								
								app/src/main/java/com/acitelight/aether/view/BookmarkPop.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,60 @@ | |||||||
|  | package com.acitelight.aether.view | ||||||
|  |  | ||||||
|  | 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 | ||||||
|  |         ) | ||||||
|  |     ) | ||||||
|  | } | ||||||
							
								
								
									
										325
									
								
								app/src/main/java/com/acitelight/aether/view/ComicGridView.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,325 @@ | |||||||
|  | package com.acitelight.aether.view | ||||||
|  |  | ||||||
|  | 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.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.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.LazyRow | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | 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.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.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import androidx.lifecycle.viewmodel.compose.viewModel | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.ToggleFullScreen | ||||||
|  | import com.acitelight.aether.model.BookMark | ||||||
|  | import com.acitelight.aether.model.Comic | ||||||
|  | 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()) {} | ||||||
|  |     ToggleFullScreen(false) | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |  | ||||||
|  |     val comic by comicGridViewModel.comic | ||||||
|  |     val record by comicGridViewModel.record | ||||||
|  |  | ||||||
|  |     if (comic != null) { | ||||||
|  |         Column { | ||||||
|  |             Card( | ||||||
|  |                 Modifier | ||||||
|  |                     .padding(horizontal = 16.dp) | ||||||
|  |                     .padding(top = 36.dp) | ||||||
|  |                     .heightIn(min = 42.dp), | ||||||
|  |                 colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||||
|  |                 shape = RoundedCornerShape(12.dp) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .heightIn(min = 42.dp) | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Text( | ||||||
|  |                         text = comic!!.comic.comic_name, | ||||||
|  |                         fontSize = 18.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                         modifier = Modifier.padding(4.dp).align(Alignment.CenterStart) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Card( | ||||||
|  |                 Modifier | ||||||
|  |                     .padding(horizontal = 16.dp) | ||||||
|  |                     .padding(top = 4.dp) | ||||||
|  |                     .heightIn(min = 42.dp), | ||||||
|  |                 colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||||
|  |                 shape = RoundedCornerShape(12.dp) | ||||||
|  |             ) { | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .heightIn(min = 42.dp) | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Text( | ||||||
|  |                         text = comic!!.comic.author, | ||||||
|  |                         fontSize = 16.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(4.dp) | ||||||
|  |                             .align(Alignment.CenterStart) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Card( | ||||||
|  |                 Modifier | ||||||
|  |                     .padding(horizontal = 16.dp) | ||||||
|  |                     .padding(top = 4.dp) | ||||||
|  |                     .heightIn(min = 42.dp), | ||||||
|  |                 colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||||
|  |                 shape = RoundedCornerShape(12.dp) | ||||||
|  |             ) { | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .heightIn(min = 42.dp) | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Text( | ||||||
|  |                         text = "Tags : ${comic!!.comic.tags.joinToString(", ")}", | ||||||
|  |                         fontSize = 16.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         maxLines = 5, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(4.dp) | ||||||
|  |                             .align(Alignment.CenterStart) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             LazyColumn( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .weight(1f) | ||||||
|  |                     .padding(top = 6.dp) | ||||||
|  |                     .clip(RoundedCornerShape(6.dp)) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 items(comicGridViewModel.chapterList) | ||||||
|  |                 { c -> | ||||||
|  |                     ChapterCard(comic!!, navController, c, comicGridViewModel) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             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 k = comic!!.getPageChapterIndex(record!!.position) | ||||||
|  |                                 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) | ||||||
|  |  | ||||||
|  |     Card( | ||||||
|  |         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()) | ||||||
|  |         { | ||||||
|  |             Row(Modifier.padding(6.dp)) | ||||||
|  |             { | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .heightIn(max = 170.dp) | ||||||
|  |                         .clip(RoundedCornerShape(8.dp)) | ||||||
|  |                         .background(Color(0x44FFFFFF)) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     AsyncImage( | ||||||
|  |                         model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                             .data(comic.getPage(c.page)) | ||||||
|  |                             .memoryCacheKey("${comic.id}/${c.page}") | ||||||
|  |                             .diskCacheKey("${comic.id}/${c.page}") | ||||||
|  |                             .build(), | ||||||
|  |                         contentDescription = null, | ||||||
|  |                         imageLoader = comicGridViewModel.imageLoader!!, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(8.dp) | ||||||
|  |                             .widthIn(max = 170.dp), | ||||||
|  |                         contentScale = ContentScale.Fit, | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 Column(modifier = Modifier.padding(horizontal = 12.dp)) { | ||||||
|  |                     Text( | ||||||
|  |                         text = chapter.name, | ||||||
|  |                         fontSize = 18.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         maxLines = 5, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(8.dp) | ||||||
|  |                             .background(Color.Transparent) | ||||||
|  |                     ) | ||||||
|  |                     Text( | ||||||
|  |                         text = "${comic.getChapterLength(chapter.page)} Pages", | ||||||
|  |                         fontSize = 14.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(8.dp) | ||||||
|  |                             .background(Color.Transparent) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             val r = comic.comic.list.subList(iv, iv + comic.getChapterLength(c.page)) | ||||||
|  |             LazyRow( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .padding(6.dp) | ||||||
|  |             ) { | ||||||
|  |                 items(r) | ||||||
|  |                 { r -> | ||||||
|  |                     Card( | ||||||
|  |                         shape = RoundedCornerShape(12.dp), | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .fillMaxWidth() | ||||||
|  |                             .wrapContentHeight() | ||||||
|  |                             .height(140.dp) | ||||||
|  |                             .padding(horizontal = 6.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)) | ||||||
|  |                                 .memoryCacheKey("${comic.id}/${r}") | ||||||
|  |                                 .diskCacheKey("${comic.id}/${r}") | ||||||
|  |                                 .build(), | ||||||
|  |                             contentDescription = null, | ||||||
|  |                             imageLoader = comicGridViewModel.imageLoader!!, | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .fillMaxSize() | ||||||
|  |                                 .clip(RoundedCornerShape(12.dp)), | ||||||
|  |                             contentScale = ContentScale.Crop, | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										327
									
								
								app/src/main/java/com/acitelight/aether/view/ComicPageView.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,327 @@ | |||||||
|  | package com.acitelight.aether.view | ||||||
|  |  | ||||||
|  | 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.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.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.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 | ||||||
|  |     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)) | ||||||
|  |                         .memoryCacheKey("${it.id}/${page}") | ||||||
|  |                         .diskCacheKey("${it.id}/${page}") | ||||||
|  |                         .build(), | ||||||
|  |                     contentDescription = null, | ||||||
|  |                     imageLoader = comicPageViewModel.imageLoader!!, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .fillMaxSize(), | ||||||
|  |                     contentScale = ContentScale.Fit, | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             androidx.compose.animation.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" | ||||||
|  |                                     ) | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             androidx.compose.animation.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(240.dp) | ||||||
|  |                             .align(Alignment.BottomCenter) | ||||||
|  |                     ) | ||||||
|  |                     { | ||||||
|  |                         items(comicPageViewModel.pageList.size) | ||||||
|  |                         { r -> | ||||||
|  |                             Card( | ||||||
|  |                                 colors = CardDefaults.cardColors(containerColor = colorScheme.primary.copy(0.8f)), | ||||||
|  |                                 shape = RoundedCornerShape(12.dp), | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .fillMaxHeight() | ||||||
|  |                                     .wrapContentHeight() | ||||||
|  |                                     .padding(vertical = 8.dp), | ||||||
|  |                                 onClick = { | ||||||
|  |                                     pagerState.requestScrollToPage(page = r) | ||||||
|  |                                 } | ||||||
|  |                             ) { | ||||||
|  |                                 Box(Modifier.padding(4.dp)) | ||||||
|  |                                 { | ||||||
|  |                                     AsyncImage( | ||||||
|  |                                         model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                                             .data(it.getPage(r)) | ||||||
|  |                                             .memoryCacheKey("${it.id}/${r}") | ||||||
|  |                                             .diskCacheKey("${it.id}/${r}") | ||||||
|  |                                             .build(), | ||||||
|  |                                         contentDescription = null, | ||||||
|  |                                         imageLoader = comicPageViewModel.imageLoader!!, | ||||||
|  |                                         modifier = Modifier | ||||||
|  |                                             .fillMaxHeight() | ||||||
|  |                                             .clip(RoundedCornerShape(12.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.first.name, | ||||||
|  |                                                 fontSize = 14.sp, | ||||||
|  |                                                 fontWeight = FontWeight.Bold, | ||||||
|  |                                                 color = Color.White, | ||||||
|  |                                                 maxLines = 1, | ||||||
|  |                                                 modifier = Modifier | ||||||
|  |                                                     .padding(2.dp) | ||||||
|  |                                                     .widthIn(max = 200.dp) | ||||||
|  |                                                     .align(Alignment.CenterVertically) | ||||||
|  |                                             ) | ||||||
|  |  | ||||||
|  |                                             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()) | ||||||
|  |             } | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,11 +1,295 @@ | |||||||
| package com.acitelight.aether.view | package com.acitelight.aether.view | ||||||
|  |  | ||||||
|  | import android.nfc.Tag | ||||||
|  | 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.widthIn | ||||||
|  | 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.lazy.grid.rememberLazyGridState | ||||||
|  | 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.verticalScroll | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.ExperimentalMaterial3Api | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Tab | ||||||
|  | import androidx.compose.material3.Text | ||||||
| import androidx.compose.runtime.Composable | 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 androidx.lifecycle.viewmodel.compose.viewModel | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.viewModel.VideoScreenViewModel | ||||||
|  | import androidx.compose.material3.ScrollableTabRow | ||||||
|  | import androidx.compose.material3.TopAppBar | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.runtime.mutableStateOf | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.graphics.Brush | ||||||
|  | import androidx.compose.ui.layout.Layout | ||||||
|  | import androidx.compose.ui.layout.Placeable | ||||||
|  | import androidx.compose.ui.modifier.modifierLocalOf | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.unit.Dp | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.Global | ||||||
|  | import com.acitelight.aether.model.Comic | ||||||
| import com.acitelight.aether.viewModel.ComicScreenViewModel | import com.acitelight.aether.viewModel.ComicScreenViewModel | ||||||
|  | import java.nio.charset.Charset | ||||||
|  |  | ||||||
| @Composable | @Composable | ||||||
| fun ComicScreen(comicScreenViewModel: ComicScreenViewModel = viewModel()) | 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 | ||||||
|  |  | ||||||
|  |     Column { | ||||||
|  |  | ||||||
|  |         VariableGrid( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .heightIn(max = 120.dp) | ||||||
|  |                 .padding(8.dp), | ||||||
|  |             rowHeight = 32.dp | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             for (i in comicScreenViewModel.tags) { | ||||||
|  |  | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .background( | ||||||
|  |                             if (included.contains(i)) Color.Green.copy(alpha = 0.65f) else colorScheme.primary, | ||||||
|  |                             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(thickness = 1.5.dp) | ||||||
|  |  | ||||||
|  |         LazyVerticalStaggeredGrid( | ||||||
|  |             columns = StaggeredGridCells.Adaptive(136.dp), | ||||||
|  |             contentPadding = PaddingValues(8.dp), | ||||||
|  |             verticalItemSpacing = 8.dp, | ||||||
|  |             horizontalArrangement = Arrangement.spacedBy(8.dp), | ||||||
|  |             state = state, | ||||||
|  |             modifier = Modifier.fillMaxSize() | ||||||
|  |         ) { | ||||||
|  |             items( | ||||||
|  |                 items = comicScreenViewModel.comics.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) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun ComicCard( | ||||||
|  |     comic: Comic, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     comicScreenViewModel: ComicScreenViewModel | ||||||
|  | ) { | ||||||
|  |     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)) | ||||||
|  |                         .memoryCacheKey("${comic.id}/${0}") | ||||||
|  |                         .diskCacheKey("${comic.id}/${0}") | ||||||
|  |                         .build(), | ||||||
|  |                     contentDescription = null, | ||||||
|  |                     imageLoader = comicScreenViewModel.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(4.dp) | ||||||
|  |                     .background(Color.Transparent) | ||||||
|  |                     .heightIn(max = 48.dp) | ||||||
|  |             ) | ||||||
|  |             Spacer(Modifier.height(4.dp)) | ||||||
|  |             Text( | ||||||
|  |                 text = "Id: ${comic.id}", | ||||||
|  |                 fontSize = 12.sp, | ||||||
|  |                 maxLines = 2, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(bottom = 4.dp).padding(horizontal = 4.dp) | ||||||
|  |                     .background(Color.Transparent) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -1,69 +1,192 @@ | |||||||
| package com.acitelight.aether.view | package com.acitelight.aether.view | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.clickable | ||||||
| import androidx.compose.foundation.layout.Arrangement | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
| import androidx.compose.foundation.layout.Column | import androidx.compose.foundation.layout.Column | ||||||
| import androidx.compose.foundation.layout.Spacer | import androidx.compose.foundation.layout.PaddingValues | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
| import androidx.compose.foundation.layout.fillMaxSize | import androidx.compose.foundation.layout.fillMaxSize | ||||||
| import androidx.compose.foundation.layout.fillMaxWidth | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
| import androidx.compose.foundation.layout.height | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.heightIn | ||||||
| import androidx.compose.foundation.layout.padding | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
| import androidx.compose.foundation.lazy.LazyColumn | import androidx.compose.foundation.lazy.LazyColumn | ||||||
| import androidx.compose.material3.Button | 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.DividerDefaults | ||||||
| import androidx.compose.material3.HorizontalDivider | import androidx.compose.material3.HorizontalDivider | ||||||
| import androidx.compose.material3.MaterialTheme | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.Text | import androidx.compose.material3.Text | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.Composable | ||||||
| import androidx.compose.runtime.collectAsState |  | ||||||
| import androidx.compose.runtime.getValue |  | ||||||
| import androidx.compose.ui.Alignment | import androidx.compose.ui.Alignment | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| import androidx.compose.ui.draw.alpha | import androidx.compose.ui.draw.alpha | ||||||
| import androidx.compose.ui.tooling.preview.Preview | 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.dp | ||||||
| import androidx.compose.ui.unit.sp | import androidx.compose.ui.unit.sp | ||||||
| import androidx.lifecycle.viewmodel.compose.viewModel |  | ||||||
| import androidx.navigation.NavController | import androidx.navigation.NavController | ||||||
| import com.acitelight.aether.Global | import androidx.navigation.NavHostController | ||||||
| import com.acitelight.aether.service.MediaManager | import coil3.compose.AsyncImage | ||||||
| import com.acitelight.aether.service.RecentManager | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.Global.updateRelate | ||||||
|  | import com.acitelight.aether.model.Comic | ||||||
|  | import com.acitelight.aether.viewModel.ComicScreenViewModel | ||||||
| import com.acitelight.aether.viewModel.HomeScreenViewModel | import com.acitelight.aether.viewModel.HomeScreenViewModel | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  |  | ||||||
| @Composable | @Composable | ||||||
| fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel(), navController: NavController) | fun HomeScreen( | ||||||
|  |     homeScreenViewModel: HomeScreenViewModel = androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel<HomeScreenViewModel>(), | ||||||
|  |     navController: NavHostController) | ||||||
| { | { | ||||||
|     if(Global.loggedIn) |     val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 }) | ||||||
|         homeScreenViewModel.Init() |  | ||||||
|     val recent by RecentManager.recent.collectAsState() |  | ||||||
|  |  | ||||||
|     LazyColumn(modifier = Modifier.fillMaxWidth()) |     HorizontalPager( | ||||||
|     { |         state = pagerState, | ||||||
|         item() |         modifier = Modifier.fillMaxSize().background(Color.Black) | ||||||
|  |     ){ | ||||||
|  |         p -> | ||||||
|  |         if(p == 0) | ||||||
|         { |         { | ||||||
|             Column { |             Column(Modifier.fillMaxHeight()) { | ||||||
|                 Text( |                 Text( | ||||||
|                     text = "Recent", |                     text = "Videos", | ||||||
|                     style = MaterialTheme.typography.headlineMedium, |                     style = MaterialTheme.typography.headlineMedium, | ||||||
|                     modifier = Modifier.padding(16.dp).align(Alignment.Start) |                     modifier = Modifier.padding(8.dp).align(Alignment.Start) | ||||||
|                 ) |                 ) | ||||||
|  |  | ||||||
|                 HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) |                 HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) | ||||||
|  |  | ||||||
|                 for(i in recent) |                 LazyColumn(modifier = Modifier.fillMaxWidth()) | ||||||
|                 { |                 { | ||||||
|                     MiniVideoCard( |                     items(homeScreenViewModel.recentManager.recentVideo) | ||||||
|                         modifier = Modifier |                     { | ||||||
|                             .padding(horizontal = 12.dp), |                             i -> | ||||||
|                         i, |                         MiniVideoCard( | ||||||
|                         { |                             modifier = Modifier | ||||||
|                             Global.sameClassVideos = recent |                                 .padding(horizontal = 12.dp), | ||||||
|                             val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }" |                             i, | ||||||
|                             navController.navigate(route) |                             { | ||||||
|                         }) |                                 updateRelate(homeScreenViewModel.recentManager.recentVideo, i) | ||||||
|                     HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color) |                                 val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }" | ||||||
|  |                                 navController.navigate(route) | ||||||
|  |                             }, homeScreenViewModel.imageLoader!!) | ||||||
|  |                         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)) | ||||||
|  |                         .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,19 +1,26 @@ | |||||||
| package com.acitelight.aether.view | package com.acitelight.aether.view | ||||||
|  |  | ||||||
|  | import android.util.Log | ||||||
| import android.widget.Toast | import android.widget.Toast | ||||||
| import androidx.compose.foundation.layout.Arrangement | import androidx.compose.foundation.layout.Arrangement | ||||||
| import androidx.compose.foundation.layout.Column | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
| import androidx.compose.foundation.layout.Spacer | import androidx.compose.foundation.layout.Spacer | ||||||
| import androidx.compose.foundation.layout.fillMaxSize | import androidx.compose.foundation.layout.fillMaxSize | ||||||
| import androidx.compose.foundation.layout.fillMaxWidth | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
| import androidx.compose.foundation.layout.height | import androidx.compose.foundation.layout.height | ||||||
| import androidx.compose.foundation.layout.padding | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
| import androidx.compose.foundation.text.KeyboardOptions | import androidx.compose.foundation.text.KeyboardOptions | ||||||
| import androidx.compose.material.icons.Icons | import androidx.compose.material.icons.Icons | ||||||
| import androidx.compose.material.icons.filled.Key | import androidx.compose.material.icons.filled.Key | ||||||
|  | import androidx.compose.material.icons.filled.Link | ||||||
| import androidx.compose.material.icons.filled.Person | import androidx.compose.material.icons.filled.Person | ||||||
|  | import androidx.compose.material.icons.filled.Security | ||||||
| import androidx.compose.material3.Button | import androidx.compose.material3.Button | ||||||
| import androidx.compose.material3.Card | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.Checkbox | ||||||
| import androidx.compose.material3.Icon | import androidx.compose.material3.Icon | ||||||
| import androidx.compose.material3.MaterialTheme | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.OutlinedTextField | import androidx.compose.material3.OutlinedTextField | ||||||
| @@ -25,19 +32,30 @@ import androidx.compose.runtime.setValue | |||||||
| import androidx.compose.ui.Alignment | import androidx.compose.ui.Alignment | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.TextStyle | ||||||
| import androidx.compose.ui.text.input.KeyboardType | import androidx.compose.ui.text.input.KeyboardType | ||||||
| import androidx.compose.ui.unit.dp | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.hilt.navigation.compose.hiltViewModel | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
| import androidx.lifecycle.viewmodel.compose.viewModel | import androidx.lifecycle.viewmodel.compose.viewModel | ||||||
|  | import com.acitelight.aether.service.ApiClient.api | ||||||
| import com.acitelight.aether.viewModel.MeScreenViewModel | import com.acitelight.aether.viewModel.MeScreenViewModel | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  |  | ||||||
| @Composable | @Composable | ||||||
| fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) | fun MeScreen(meScreenViewModel: MeScreenViewModel = androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel<MeScreenViewModel>()) { | ||||||
| { |  | ||||||
|     val context = LocalContext.current |     val context = LocalContext.current | ||||||
|     var username by meScreenViewModel.username; |     var username by meScreenViewModel.username; | ||||||
|     var privateKey by meScreenViewModel.privateKey; |     var privateKey by meScreenViewModel.privateKey; | ||||||
|  |     var url by meScreenViewModel.url | ||||||
|  |     var cert by meScreenViewModel.cert | ||||||
|  |  | ||||||
|     Column( |     val uss by meScreenViewModel.uss.collectAsState(initial = false) | ||||||
|  |  | ||||||
|  |     LazyColumn( | ||||||
|         modifier = Modifier |         modifier = Modifier | ||||||
|             .fillMaxSize() |             .fillMaxSize() | ||||||
|             .padding(8.dp), |             .padding(8.dp), | ||||||
| @@ -45,62 +63,164 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) | |||||||
|         verticalArrangement = Arrangement.Top |         verticalArrangement = Arrangement.Top | ||||||
|     ) { |     ) { | ||||||
|         // Card component for a clean, contained UI block |         // Card component for a clean, contained UI block | ||||||
|         Card( |         item{ | ||||||
|             modifier = Modifier |             Card( | ||||||
|                 .fillMaxWidth() |  | ||||||
|                 .padding(8.dp) |  | ||||||
|         ) { |  | ||||||
|             Column( |  | ||||||
|                 modifier = Modifier |                 modifier = Modifier | ||||||
|                     .padding(16.dp) |                     .fillMaxWidth() | ||||||
|                     .fillMaxWidth(), |                     .padding(8.dp) | ||||||
|                 horizontalAlignment = Alignment.CenterHorizontally |             ) | ||||||
|             ) { |             { | ||||||
|                 Text( |                 Column( | ||||||
|                     text = "Account Setting", |                     modifier = Modifier | ||||||
|                     style = MaterialTheme.typography.headlineMedium, |                         .padding(16.dp) | ||||||
|                     modifier = Modifier.padding(bottom = 16.dp).align(Alignment.Start) |                         .fillMaxWidth(), | ||||||
|                 ) |                     horizontalAlignment = Alignment.CenterHorizontally | ||||||
|  |  | ||||||
|                 // Username input field |  | ||||||
|                 OutlinedTextField( |  | ||||||
|                     value = username, |  | ||||||
|                     onValueChange = { username = it }, |  | ||||||
|                     label = { Text("Username") }, |  | ||||||
|                     leadingIcon = { |  | ||||||
|                         Icon(Icons.Default.Person, contentDescription = "Username") |  | ||||||
|                     }, |  | ||||||
|                     singleLine = true, |  | ||||||
|                     modifier = Modifier.fillMaxWidth() |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|                 Spacer(modifier = Modifier.height(16.dp)) |  | ||||||
|  |  | ||||||
|                 // Private key input field |  | ||||||
|                 OutlinedTextField( |  | ||||||
|                     value = privateKey, |  | ||||||
|                     onValueChange = { privateKey = it }, |  | ||||||
|                     label = { Text("Key") }, |  | ||||||
|                     leadingIcon = { |  | ||||||
|                         Icon(Icons.Default.Key, contentDescription = "Key") |  | ||||||
|                     }, |  | ||||||
|                     singleLine = true, |  | ||||||
|                     keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), |  | ||||||
|                     modifier = Modifier.fillMaxWidth() |  | ||||||
|                 ) |  | ||||||
|  |  | ||||||
|                 Spacer(modifier = Modifier.height(24.dp)) |  | ||||||
|  |  | ||||||
|                 // Save Button |  | ||||||
|                 Button( |  | ||||||
|                     onClick = { |  | ||||||
|                         meScreenViewModel.updateAccount(username, privateKey, context) |  | ||||||
|                     }, |  | ||||||
|                     modifier = Modifier.fillMaxWidth() |  | ||||||
|                 ) { |                 ) { | ||||||
|                     Text("Save") |                     Text( | ||||||
|  |                         text = "Account Setting", | ||||||
|  |                         style = MaterialTheme.typography.headlineMedium, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(bottom = 16.dp) | ||||||
|  |                             .align(Alignment.Start) | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     // Username input field | ||||||
|  |                     OutlinedTextField( | ||||||
|  |                         value = username, | ||||||
|  |                         onValueChange = { username = it }, | ||||||
|  |                         label = { Text("Username") }, | ||||||
|  |                         leadingIcon = { | ||||||
|  |                             Icon(Icons.Default.Person, contentDescription = "Username") | ||||||
|  |                         }, | ||||||
|  |                         singleLine = true, | ||||||
|  |                         modifier = Modifier.fillMaxWidth() | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     Spacer(modifier = Modifier.height(16.dp)) | ||||||
|  |  | ||||||
|  |                     // Private key input field | ||||||
|  |                     OutlinedTextField( | ||||||
|  |                         value = privateKey, | ||||||
|  |                         onValueChange = { privateKey = it }, | ||||||
|  |                         label = { Text("Key") }, | ||||||
|  |                         leadingIcon = { | ||||||
|  |                             Icon(Icons.Default.Key, contentDescription = "Key") | ||||||
|  |                         }, | ||||||
|  |                         singleLine = true, | ||||||
|  |                         keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), | ||||||
|  |                         modifier = Modifier.fillMaxWidth() | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     Spacer(modifier = Modifier.height(24.dp)) | ||||||
|  |  | ||||||
|  |                     // Save Button | ||||||
|  |                     Button( | ||||||
|  |                         onClick = { | ||||||
|  |                             meScreenViewModel.updateAccount(username, privateKey) | ||||||
|  |                         }, | ||||||
|  |                         modifier = Modifier.fillMaxWidth(), | ||||||
|  |                         enabled = privateKey != "******" | ||||||
|  |                     ) { | ||||||
|  |                         Text("Save") | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Card( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .padding(8.dp) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Column( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .padding(16.dp) | ||||||
|  |                         .fillMaxWidth(), | ||||||
|  |                     horizontalAlignment = Alignment.CenterHorizontally | ||||||
|  |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         text = "Server Setting", | ||||||
|  |                         style = MaterialTheme.typography.headlineMedium, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(bottom = 16.dp) | ||||||
|  |                             .align(Alignment.Start) | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     Spacer(modifier = Modifier.width(8.dp)) | ||||||
|  |  | ||||||
|  |                     // Username input field | ||||||
|  |                     OutlinedTextField( | ||||||
|  |                         value = url, | ||||||
|  |                         onValueChange = { url = it }, | ||||||
|  |                         label = { Text("Url") }, | ||||||
|  |                         leadingIcon = { | ||||||
|  |                             Icon(Icons.Default.Link, contentDescription = "Url") | ||||||
|  |                         }, | ||||||
|  |                         singleLine = true, | ||||||
|  |                         modifier = Modifier.fillMaxWidth() | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     Spacer(modifier = Modifier.height(4.dp)) | ||||||
|  |  | ||||||
|  |                     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 | ||||||
|  |                     Row{ | ||||||
|  |                         Button( | ||||||
|  |                             onClick = { | ||||||
|  |                                 meScreenViewModel.updateServer(url, cert) | ||||||
|  |                             }, | ||||||
|  |                             modifier = Modifier.weight(0.5f).padding(8.dp) | ||||||
|  |                         ) { | ||||||
|  |                             Text("Save") | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         Button( | ||||||
|  |                             onClick = { | ||||||
|  |                                 meScreenViewModel.viewModelScope.launch { | ||||||
|  |                                     Log.i("Delay Analyze", "Start Abyss Hello") | ||||||
|  |                                     val h = api!!.hello() | ||||||
|  |                                     Log.i("Delay Analyze", "Abyss Hello: ${h.string()}") | ||||||
|  |                                 } | ||||||
|  |                             }, | ||||||
|  |                             modifier = Modifier.weight(0.5f).padding(8.dp) | ||||||
|  |                         ) { | ||||||
|  |                             Text("Ping") | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,260 @@ | |||||||
|  | package com.acitelight.aether.view | ||||||
|  |  | ||||||
|  | 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.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | 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.filled.Pause | ||||||
|  | import androidx.compose.material.icons.filled.Stop | ||||||
|  | import androidx.compose.material.icons.* | ||||||
|  | import androidx.compose.material.icons.filled.Delete | ||||||
|  | import androidx.compose.material.icons.filled.PlayArrow | ||||||
|  | import androidx.compose.material3.Button | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.CardElevation | ||||||
|  | import androidx.compose.material3.DividerDefaults | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.LinearProgressIndicator | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.ProgressIndicatorDefaults | ||||||
|  | import androidx.compose.material3.Surface | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.runtime.snapshots.SnapshotStateList | ||||||
|  | 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.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import androidx.navigation.Navigator | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.Global.updateRelate | ||||||
|  | import com.acitelight.aether.model.DownloadItemState | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.viewModel.TransmissionScreenViewModel | ||||||
|  | import com.tonyodev.fetch2.Download | ||||||
|  | import com.tonyodev.fetch2.FetchListener | ||||||
|  | import com.tonyodev.fetch2.Status | ||||||
|  | import com.tonyodev.fetch2core.DownloadBlock | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import java.io.File | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun TransmissionScreen(navigator: NavHostController, transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel<TransmissionScreenViewModel>()) { | ||||||
|  |     val downloads = transmissionScreenViewModel.downloads | ||||||
|  |     LazyColumn( | ||||||
|  |         modifier = Modifier.fillMaxWidth(), | ||||||
|  |         verticalArrangement = Arrangement.spacedBy(8.dp) | ||||||
|  |     ) { | ||||||
|  |         items(downloads, key = { it.id }) { item -> | ||||||
|  |             DownloadCard( | ||||||
|  |                 navigator = navigator, | ||||||
|  |                 viewModel = transmissionScreenViewModel, | ||||||
|  |                 model = item, | ||||||
|  |                 onPause = { transmissionScreenViewModel.pause(item.id) }, | ||||||
|  |                 onResume = { transmissionScreenViewModel.resume(item.id) }, | ||||||
|  |                 onCancel = { transmissionScreenViewModel.cancel(item.id) }, | ||||||
|  |                 onDelete = { transmissionScreenViewModel.delete(item.id, true) } | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | private fun DownloadCard( | ||||||
|  |     navigator: NavHostController, | ||||||
|  |     viewModel: TransmissionScreenViewModel, | ||||||
|  |     model: DownloadItemState, | ||||||
|  |     onPause: () -> Unit, | ||||||
|  |     onResume: () -> Unit, | ||||||
|  |     onCancel: () -> Unit, | ||||||
|  |     onDelete: () -> 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("isComic", "") != "true" | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         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 route = "video_player_route/${"${model.klass}/${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) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Box(Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |                 .padding(top = 5.dp)) | ||||||
|  |             { | ||||||
|  |                 Card( | ||||||
|  |                     shape = RoundedCornerShape(8.dp), | ||||||
|  |                     modifier = Modifier.align(Alignment.CenterStart) | ||||||
|  |                 ) { | ||||||
|  |                     AsyncImage( | ||||||
|  |                         model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                             .data( | ||||||
|  |                                 File( | ||||||
|  |                                     viewModel.context.getExternalFilesDir(null), | ||||||
|  |                                     "videos/${model.klass}/${model.vid}/cover.jpg" | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                             .diskCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                             .build(), | ||||||
|  |                         contentDescription = null, | ||||||
|  |                         modifier = Modifier.heightIn(max = 100.dp), | ||||||
|  |                         contentScale = ContentScale.Fit | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 Column(Modifier.align(Alignment.BottomEnd)) { | ||||||
|  |                     Text( | ||||||
|  |                         text = "${model.progress}%", | ||||||
|  |                         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 = { 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 = onResume) { | ||||||
|  |                             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)) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,31 +1,55 @@ | |||||||
| package com.acitelight.aether.view | package com.acitelight.aether.view | ||||||
|  |  | ||||||
| import android.R.id.tabs | import android.widget.Toast | ||||||
|  | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | 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.background | ||||||
|  | import androidx.compose.foundation.clickable | ||||||
|  | import androidx.compose.foundation.combinedClickable | ||||||
| import androidx.compose.foundation.layout.Arrangement | import androidx.compose.foundation.layout.Arrangement | ||||||
| import androidx.compose.foundation.layout.Box | import androidx.compose.foundation.layout.Box | ||||||
| import androidx.compose.foundation.layout.Column | import androidx.compose.foundation.layout.Column | ||||||
| import androidx.compose.foundation.layout.PaddingValues | import androidx.compose.foundation.layout.PaddingValues | ||||||
| import androidx.compose.foundation.layout.Row | import androidx.compose.foundation.layout.Row | ||||||
| import androidx.compose.foundation.layout.Spacer | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
| import androidx.compose.foundation.layout.fillMaxSize | import androidx.compose.foundation.layout.fillMaxSize | ||||||
| import androidx.compose.foundation.layout.fillMaxWidth | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
| import androidx.compose.foundation.layout.height | import androidx.compose.foundation.layout.height | ||||||
| import androidx.compose.foundation.layout.heightIn | import androidx.compose.foundation.layout.heightIn | ||||||
| import androidx.compose.foundation.layout.padding | 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.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
| import androidx.compose.foundation.lazy.grid.GridCells | import androidx.compose.foundation.lazy.grid.GridCells | ||||||
| import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | ||||||
| import androidx.compose.foundation.lazy.grid.items | import androidx.compose.foundation.lazy.grid.items | ||||||
|  | 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.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.Button | ||||||
| import androidx.compose.material3.Card | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.CheckboxDefaults.colors | ||||||
|  | import androidx.compose.material3.DividerDefaults | ||||||
| import androidx.compose.material3.ExperimentalMaterial3Api | import androidx.compose.material3.ExperimentalMaterial3Api | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.LocalTextStyle | ||||||
| import androidx.compose.material3.MaterialTheme | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.PrimaryScrollableTabRow |  | ||||||
| import androidx.compose.material3.Tab | 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.material3.Text | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.Composable | ||||||
| import androidx.compose.runtime.collectAsState | import androidx.compose.runtime.collectAsState | ||||||
| @@ -39,20 +63,35 @@ import androidx.compose.ui.unit.sp | |||||||
| import androidx.lifecycle.viewmodel.compose.viewModel | import androidx.lifecycle.viewmodel.compose.viewModel | ||||||
| import coil3.compose.AsyncImage | import coil3.compose.AsyncImage | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import com.acitelight.aether.service.MediaManager |  | ||||||
| import com.acitelight.aether.viewModel.VideoScreenViewModel | import com.acitelight.aether.viewModel.VideoScreenViewModel | ||||||
| import androidx.compose.material3.PrimaryTabRow |  | ||||||
| import androidx.compose.material3.ScrollableTabRow | import androidx.compose.material3.ScrollableTabRow | ||||||
| import androidx.compose.runtime.LaunchedEffect | import androidx.compose.material3.Surface | ||||||
|  | import androidx.compose.material3.TextField | ||||||
|  | import androidx.compose.material3.TextFieldDefaults | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.runtime.mutableStateOf | ||||||
|  | import androidx.compose.runtime.setValue | ||||||
| import androidx.compose.ui.Alignment | import androidx.compose.ui.Alignment | ||||||
| import androidx.compose.ui.graphics.Brush | import androidx.compose.ui.graphics.Brush | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.compose.ui.platform.LocalContext | ||||||
| import androidx.compose.ui.text.style.TextOverflow | import androidx.compose.ui.platform.LocalDensity | ||||||
|  | import androidx.compose.ui.text.AnnotatedString | ||||||
|  | import androidx.compose.ui.text.TextStyle | ||||||
|  | import androidx.compose.ui.text.rememberTextMeasurer | ||||||
|  | import androidx.compose.ui.text.style.TextAlign | ||||||
|  | import androidx.compose.ui.unit.min | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
| import androidx.navigation.NavHostController | import androidx.navigation.NavHostController | ||||||
| import coil3.request.ImageRequest | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.CardPage | ||||||
| import com.acitelight.aether.Global | import com.acitelight.aether.Global | ||||||
| import kotlinx.coroutines.flow.first | import com.acitelight.aether.Global.updateRelate | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
| import java.nio.charset.Charset | import java.nio.charset.Charset | ||||||
|  | import java.security.KeyPair | ||||||
|  |  | ||||||
| fun String.toHex(): String { | fun String.toHex(): String { | ||||||
|     return this.toByteArray().joinToString("") { "%02x".format(it) } |     return this.toByteArray().joinToString("") { "%02x".format(it) } | ||||||
| @@ -70,69 +109,246 @@ fun String.hexToString(charset: Charset = Charsets.UTF_8): String { | |||||||
| } | } | ||||||
|  |  | ||||||
| @Composable | @Composable | ||||||
| fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navController: NavHostController) | fun VideoScreen( | ||||||
| { |     videoScreenViewModel: VideoScreenViewModel = hiltViewModel<VideoScreenViewModel>(), | ||||||
|     val videoList by videoScreenViewModel.videos.collectAsState() |     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 | ||||||
|  |  | ||||||
|     Column( |     if (doneInit) | ||||||
|         modifier = Modifier.fillMaxSize() // 或至少 fillMaxWidth() |         CardPage(title = "Videos") { | ||||||
|     ){ |             Box(Modifier.fillMaxSize()) | ||||||
|         TopRow(videoScreenViewModel); |             { | ||||||
|  |                 Column( | ||||||
|  |                     modifier = Modifier.fillMaxSize() | ||||||
|  |                 ) { | ||||||
|  |                     // TopRow(videoScreenViewModel); | ||||||
|  |                     Row(Modifier.padding(bottom = 4.dp)) | ||||||
|  |                     { | ||||||
|  |                         Card( | ||||||
|  |                             shape = RoundedCornerShape(8.dp), | ||||||
|  |                             colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .align(Alignment.CenterVertically) | ||||||
|  |                                 .padding(horizontal = 2.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" | ||||||
|  |                                 ) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |  | ||||||
|         LazyVerticalGrid( |                         Card( | ||||||
|             columns = GridCells.Fixed(2), |                             shape = RoundedCornerShape(8.dp), | ||||||
|             contentPadding = PaddingValues(8.dp), |                             colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||||
|             verticalArrangement = Arrangement.spacedBy(8.dp), |                             modifier = Modifier | ||||||
|             horizontalArrangement = Arrangement.spacedBy(8.dp) |                                 .align(Alignment.CenterVertically) | ||||||
|         ) |                                 .padding(horizontal = 2.dp) | ||||||
|         { |                                 .height(36.dp), | ||||||
|             items(videoList) { video -> |                             onClick = { | ||||||
|                 VideoCard(video, navController, videoScreenViewModel) |                                 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(bottom = 8.dp), | ||||||
|  |                         1.5.dp, | ||||||
|  |                         DividerDefaults.color | ||||||
|  |                     ) | ||||||
|  |                     LazyVerticalStaggeredGrid( | ||||||
|  |                         columns = StaggeredGridCells.Adaptive(160.dp), | ||||||
|  |                         contentPadding = PaddingValues(8.dp), | ||||||
|  |                         verticalItemSpacing = 8.dp, | ||||||
|  |                         horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy( | ||||||
|  |                             8.dp | ||||||
|  |                         ), | ||||||
|  |                         state = state, | ||||||
|  |                         modifier = Modifier.fillMaxSize() | ||||||
|  |                     ) { | ||||||
|  |                         items( | ||||||
|  |                             items = videoScreenViewModel.videoLibrary.classesMap.getOrDefault( | ||||||
|  |                                 videoScreenViewModel.videoLibrary.classes.getOrNull( | ||||||
|  |                                     tabIndex | ||||||
|  |                                 ), listOf() | ||||||
|  |                             ).filter { it.video.name.contains(searchFilter) }, | ||||||
|  |                             key = { "${it.klass}/${it.id}" } | ||||||
|  |                         ) { video -> | ||||||
|  |                             androidx.compose.foundation.layout.Box( | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .fillMaxWidth() | ||||||
|  |                                     .wrapContentHeight() | ||||||
|  |                             ) { | ||||||
|  |                                 VideoCard(video, 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 | ||||||
|  |                                             ) | ||||||
|  |                                         ) | ||||||
|  |                                     } | ||||||
|  |                                 ) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| @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 | @Composable | ||||||
| fun VideoCard(video: Video, navController: NavHostController, videoScreenViewModel: VideoScreenViewModel) { | fun CatalogueItemRow( | ||||||
|     val videoList by videoScreenViewModel.videos.collectAsState() |     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) | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VideoCard( | ||||||
|  |     video: Video, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     videoScreenViewModel: VideoScreenViewModel | ||||||
|  | ) { | ||||||
|  |     val tabIndex by videoScreenViewModel.tabIndex; | ||||||
|     Card( |     Card( | ||||||
|         shape = RoundedCornerShape(6.dp), |  | ||||||
|         modifier = Modifier |         modifier = Modifier | ||||||
|             .fillMaxWidth() |             .fillMaxWidth() | ||||||
|             .wrapContentHeight(), |             .wrapContentHeight() | ||||||
|         onClick = { |             .combinedClickable( | ||||||
|             Global.sameClassVideos = videoList |                 onClick = { | ||||||
|             val route = "video_player_route/${ "${video.klass}/${video.id}".toHex() }" |                     updateRelate( | ||||||
|             navController.navigate(route) |                         videoScreenViewModel.videoLibrary.classesMap[videoScreenViewModel.videoLibrary.classes[tabIndex]] | ||||||
|         } |                             ?: mutableStateListOf(), video | ||||||
|  |                     ) | ||||||
|  |                     val route = "video_player_route/${"${video.klass}/${video.id}".toHex()}" | ||||||
|  |                     navController.navigate(route) | ||||||
|  |                 }, | ||||||
|  |                 onLongClick = { | ||||||
|  |                     videoScreenViewModel.viewModelScope.launch { | ||||||
|  |                         videoScreenViewModel.download(video) | ||||||
|  |                     } | ||||||
|  |                     Toast.makeText( | ||||||
|  |                         videoScreenViewModel.context, | ||||||
|  |                         "Start downloading ${video.video.name}", | ||||||
|  |                         Toast.LENGTH_SHORT | ||||||
|  |                     ).show() | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |         shape = RoundedCornerShape(6.dp), | ||||||
|     ) { |     ) { | ||||||
|         Column( |         Column( | ||||||
|             modifier = Modifier |             modifier = Modifier | ||||||
|                 .fillMaxWidth() |                 .fillMaxWidth(), | ||||||
|         )  { |         ) { | ||||||
|             Box(modifier = Modifier.fillMaxSize()){ |             Box(modifier = Modifier.fillMaxSize()) { | ||||||
|  |  | ||||||
|                 AsyncImage( |                 AsyncImage( | ||||||
|                     model = ImageRequest.Builder(LocalContext.current) |                     model = ImageRequest.Builder(LocalContext.current) | ||||||
|                         .data(video.getCover()) |                         .data(video.getCover()) | ||||||
| @@ -142,39 +358,78 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod | |||||||
|                     contentDescription = null, |                     contentDescription = null, | ||||||
|                     modifier = Modifier |                     modifier = Modifier | ||||||
|                         .fillMaxSize(), |                         .fillMaxSize(), | ||||||
|                     contentScale = ContentScale.Crop |                     contentScale = ContentScale.Fit, | ||||||
|  |                     imageLoader = videoScreenViewModel.imageLoader!! | ||||||
|                 ) |                 ) | ||||||
|  |  | ||||||
|                 Text( |                 Text( | ||||||
|                     modifier = Modifier.align(Alignment.BottomEnd).padding(2.dp), |                     modifier = Modifier | ||||||
|                     text = formatTime(video.video.duration), fontSize = 12.sp, color = Color.White, fontWeight = FontWeight.Bold) |                         .align(Alignment.BottomEnd) | ||||||
|  |                         .padding(2.dp), | ||||||
|  |                     text = formatTime(video.video.duration), | ||||||
|  |                     fontSize = 12.sp, | ||||||
|  |                     fontWeight = FontWeight.Bold | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|                 Box( |                 Box( | ||||||
|                     Modifier |                     Modifier | ||||||
|                         .fillMaxWidth() |                         .fillMaxWidth() | ||||||
|                         .height(24.dp) |                         .height(24.dp) | ||||||
|                         .background( brush = Brush.verticalGradient( |                         .background( | ||||||
|                             colors = listOf( |                             brush = Brush.verticalGradient( | ||||||
|                                 Color.Transparent, |                                 colors = listOf( | ||||||
|                                 Color.Black.copy(alpha = 0.45f) |                                     Color.Transparent, | ||||||
|  |                                     Color.Black.copy(alpha = 0.45f) | ||||||
|  |                                 ) | ||||||
|                             ) |                             ) | ||||||
|                         )) |                         ) | ||||||
|                         .align(Alignment.BottomCenter)) |                         .align(Alignment.BottomCenter) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 if (video.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( | ||||||
|                 text = video.video.name, |                 text = video.video.name, | ||||||
|                 fontSize = 14.sp, |                 fontSize = 12.sp, | ||||||
|                 fontWeight = FontWeight.Bold, |                 fontWeight = FontWeight.Bold, | ||||||
|                 maxLines = 2, |                 maxLines = 4, | ||||||
|                 modifier = Modifier.padding(8.dp).background(Color.Transparent).heightIn(48.dp) |                 modifier = Modifier | ||||||
|  |                     .padding(8.dp) | ||||||
|  |                     .background(Color.Transparent) | ||||||
|  |                     .heightIn(min = 24.dp), | ||||||
|  |                 lineHeight = 14.sp | ||||||
|             ) |             ) | ||||||
|             Spacer(modifier = Modifier.weight(1f)) |             Spacer(modifier = Modifier.weight(1f)) | ||||||
|             Row( |             Row( | ||||||
|                 modifier = Modifier.padding(horizontal = 8.dp), |                 modifier = Modifier.padding(horizontal = 8.dp), | ||||||
|                 horizontalArrangement = Arrangement.SpaceBetween, |                 horizontalArrangement = Arrangement.SpaceBetween, | ||||||
|             ) { |             ) { | ||||||
|                 Text("Class", fontSize = 12.sp) |                 Text("Class: ", fontSize = 10.sp, maxLines = 1) | ||||||
|                 Text("${video.klass}", fontSize = 12.sp) |                 Text(video.klass, fontSize = 10.sp, maxLines = 1) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Row( | ||||||
|  |                 modifier = Modifier.padding(horizontal = 8.dp).padding(bottom = 6.dp), | ||||||
|  |                 horizontalArrangement = Arrangement.SpaceBetween, | ||||||
|  |             ) { | ||||||
|  |                 Text("Id: ", fontSize = 10.sp, maxLines = 1, lineHeight = 10.sp) | ||||||
|  |                 Text(video.id, fontSize = 10.sp, maxLines = 1, lineHeight = 10.sp) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -0,0 +1,72 @@ | |||||||
|  | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.runtime.mutableStateOf | ||||||
|  | import androidx.compose.runtime.remember | ||||||
|  | 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.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.createOkHttp | ||||||
|  | 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 | ||||||
|  | )  : ViewModel() | ||||||
|  | { | ||||||
|  |     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(createOkHttp())) | ||||||
|  |             } | ||||||
|  |             .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,77 @@ | |||||||
|  | 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.createOkHttp | ||||||
|  | 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 | ||||||
|  | ) : 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(createOkHttp())) | ||||||
|  |             } | ||||||
|  |             .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,73 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
| import com.acitelight.aether.model.Comic | import com.acitelight.aether.model.Comic | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.ComicResponse | ||||||
|  | import com.acitelight.aether.service.ApiClient.createOkHttp | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import kotlinx.coroutines.flow.MutableStateFlow | import kotlinx.coroutines.flow.MutableStateFlow | ||||||
| import kotlinx.coroutines.flow.StateFlow | import kotlinx.coroutines.flow.StateFlow | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
| class ComicScreenViewModel : ViewModel() | @HiltViewModel | ||||||
| { | class ComicScreenViewModel @Inject constructor( | ||||||
|     private val _comics = MutableStateFlow<List<Comic>>(emptyList()) |     @ApplicationContext private val context: Context, | ||||||
|     val comics: StateFlow<List<Comic>> = _comics |     val mediaManager: MediaManager | ||||||
|  | ) : ViewModel() { | ||||||
|  |  | ||||||
|     init |     var imageLoader: ImageLoader? = null; | ||||||
|     { |  | ||||||
|       //  viewModelScope.launch { |     val comics = mutableStateListOf<Comic>() | ||||||
|       //      val l = MediaManager.listComics() |     val excluded = mutableStateListOf<String>() | ||||||
|        //     _comics.value = l.map { MediaManager.queryComicInfo(it) } |     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(createOkHttp())) | ||||||
|  |             } | ||||||
|  |             .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,81 +1,35 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
| import android.app.Application | import android.content.Context | ||||||
| import androidx.compose.runtime.Composable |  | ||||||
| import androidx.compose.runtime.mutableStateOf |  | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.compose.runtime.State |  | ||||||
| import androidx.compose.runtime.mutableIntStateOf |  | ||||||
| import androidx.compose.runtime.remember |  | ||||||
| import androidx.compose.ui.platform.LocalContext |  | ||||||
| import androidx.datastore.preferences.core.stringPreferencesKey |  | ||||||
| import androidx.lifecycle.AndroidViewModel |  | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import com.acitelight.aether.Global | import coil3.ImageLoader | ||||||
| import com.acitelight.aether.dataStore | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.service.ApiClient.createOkHttp | ||||||
| import com.acitelight.aether.model.VideoQueryIndex |  | ||||||
| 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 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 kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
|  | import javax.inject.Inject | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  |  | ||||||
| class HomeScreenViewModel(application: Application) : AndroidViewModel(application) |  | ||||||
|  | @HiltViewModel | ||||||
|  | class HomeScreenViewModel @Inject constructor( | ||||||
|  |     val recentManager: RecentManager, | ||||||
|  |     @ApplicationContext val context: Context | ||||||
|  | ) : ViewModel() | ||||||
| { | { | ||||||
|     private val dataStore = application.dataStore |     var imageLoader: ImageLoader? = null | ||||||
|     private val USER_NAME_KEY = stringPreferencesKey("user_name") |  | ||||||
|     private val PRIVATE_KEY   = stringPreferencesKey("private_key") |  | ||||||
|  |  | ||||||
|     val userNameFlow: Flow<String> = dataStore.data.map { preferences -> |     init{ | ||||||
|         preferences[USER_NAME_KEY] ?: "" |         imageLoader =  ImageLoader.Builder(context) | ||||||
|     } |             .components { | ||||||
|  |                 add(OkHttpNetworkFetcherFactory(createOkHttp())) | ||||||
|     val privateKeyFlow: Flow<String> = dataStore.data.map {  preferences -> |  | ||||||
|         preferences[PRIVATE_KEY] ?: "" |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     var _init = false |  | ||||||
|  |  | ||||||
|     @Composable |  | ||||||
|     fun Init(){ |  | ||||||
|         if(_init) return |  | ||||||
|         _init = true |  | ||||||
|  |  | ||||||
|         val context = LocalContext.current |  | ||||||
|         remember { |  | ||||||
|             viewModelScope.launch { |  | ||||||
|                 RecentManager.Query(context) |  | ||||||
|             } |             } | ||||||
|         } |             .build() | ||||||
|     } |  | ||||||
|  |  | ||||||
|     init { |  | ||||||
|         viewModelScope.launch { |         viewModelScope.launch { | ||||||
|             val u = userNameFlow.first() |             recentManager.queryVideo(context) | ||||||
|             val p = privateKeyFlow.first() |             recentManager.queryComic(context) | ||||||
|  |  | ||||||
|             if(u=="" || p=="") return@launch |  | ||||||
|  |  | ||||||
|             try{ |  | ||||||
|                 if (MediaManager.token == "null") |  | ||||||
|                     MediaManager.token = AuthManager.fetchToken( |  | ||||||
|                         ApiClient.base, |  | ||||||
|                         u, |  | ||||||
|                         p |  | ||||||
|                     )!! |  | ||||||
|  |  | ||||||
|                 Global.loggedIn = true |  | ||||||
|             }catch(e: Exception) |  | ||||||
|             { |  | ||||||
|                 print(e.message) |  | ||||||
|             } |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -5,10 +5,13 @@ import android.content.Context | |||||||
| import android.widget.Toast | import android.widget.Toast | ||||||
| import androidx.compose.runtime.mutableStateOf | import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.core.net.toUri | ||||||
| import androidx.datastore.preferences.core.edit | import androidx.datastore.preferences.core.edit | ||||||
| import androidx.datastore.preferences.core.stringPreferencesKey | import androidx.datastore.preferences.core.stringPreferencesKey | ||||||
| import androidx.lifecycle.AndroidViewModel | import androidx.lifecycle.AndroidViewModel | ||||||
|  | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
|  | import com.acitelight.aether.AetherApp | ||||||
| import com.acitelight.aether.Global | import com.acitelight.aether.Global | ||||||
| import com.acitelight.aether.dataStore | import com.acitelight.aether.dataStore | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| @@ -20,54 +23,127 @@ import kotlinx.coroutines.flow.MutableStateFlow | |||||||
| import kotlinx.coroutines.flow.first | import kotlinx.coroutines.flow.first | ||||||
| import kotlinx.coroutines.flow.map | import kotlinx.coroutines.flow.map | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
|  | import javax.inject.Inject | ||||||
|  | import com.acitelight.aether.service.* | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  |  | ||||||
| class MeScreenViewModel(application: Application) : AndroidViewModel(application) { | @HiltViewModel | ||||||
|     private val dataStore = application.dataStore | class MeScreenViewModel @Inject constructor( | ||||||
|     private val USER_NAME_KEY = stringPreferencesKey("user_name") |     private val settingsDataStoreManager: SettingsDataStoreManager, | ||||||
|     private val PRIVATE_KEY = stringPreferencesKey("private_key") |     @ApplicationContext private val context: Context, | ||||||
|  |     val mediaManager: MediaManager | ||||||
|     val userNameFlow: Flow<String> = dataStore.data.map { preferences -> | ) : ViewModel() { | ||||||
|         preferences[USER_NAME_KEY] ?: "" |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     val privateKeyFlow: Flow<String> = dataStore.data.map { preferences -> |  | ||||||
|         preferences[PRIVATE_KEY] ?: "" |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     val username = mutableStateOf(""); |     val username = mutableStateOf(""); | ||||||
|     val privateKey = mutableStateOf("") |     val privateKey = mutableStateOf("") | ||||||
|  |     val url = mutableStateOf(""); | ||||||
|  |     val cert = mutableStateOf("") | ||||||
|  |  | ||||||
|  |     val uss = settingsDataStoreManager.useSelfSignedFlow | ||||||
|  |  | ||||||
|     init { |     init { | ||||||
|         viewModelScope.launch { |         viewModelScope.launch { | ||||||
|             username.value = userNameFlow.first() |             username.value = settingsDataStoreManager.userNameFlow.first() | ||||||
|             privateKey.value = if (privateKeyFlow.first() == "") "" else "******" |             privateKey.value = if (settingsDataStoreManager.privateKeyFlow.first() == "") "" else "******" | ||||||
|  |             url.value = settingsDataStoreManager.urlFlow.first() | ||||||
|  |             cert.value = settingsDataStoreManager.certFlow.first() | ||||||
|  |  | ||||||
|  |             if(username.value=="" || privateKey.value=="" || url.value=="") return@launch | ||||||
|  |  | ||||||
|  |             try{ | ||||||
|  |                 val usedUrl = ApiClient.apply(context, url.value, if(uss.first()) cert.value else "") | ||||||
|  |  | ||||||
|  |                 if (mediaManager.token == "null") | ||||||
|  |                     mediaManager.token = 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 updateAccount(u: String, p: String, context: Context) { |     fun onUseSelfSignedCheckedChange(isChecked: Boolean) { | ||||||
|         viewModelScope.launch { |         viewModelScope.launch { | ||||||
|             dataStore.edit { preferences -> |             settingsDataStoreManager.saveUseSelfSigned(isChecked) | ||||||
|                 preferences[USER_NAME_KEY] = u |         } | ||||||
|                 preferences[PRIVATE_KEY] = p |     } | ||||||
|  |  | ||||||
|  |     fun updateServer(u: String, c: String) | ||||||
|  |     { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             settingsDataStoreManager.saveUrl(u) | ||||||
|  |             settingsDataStoreManager.saveCert(c) | ||||||
|  |  | ||||||
|  |             Global.loggedIn = false | ||||||
|  |  | ||||||
|  |             val us = settingsDataStoreManager.userNameFlow.first() | ||||||
|  |             val p = settingsDataStoreManager.privateKeyFlow.first() | ||||||
|  |  | ||||||
|  |             if (u == "" || p == "" || us == "") return@launch | ||||||
|  |  | ||||||
|  |             try { | ||||||
|  |                 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() | ||||||
|  |                 mediaManager.token = AuthManager.fetchToken( | ||||||
|  |                     us, | ||||||
|  |                     p | ||||||
|  |                 )!! | ||||||
|  |  | ||||||
|  |                 Global.loggedIn = true | ||||||
|  |                 Toast.makeText(context, "Server Updated, Used Url: $usedUrl", Toast.LENGTH_SHORT).show() | ||||||
|  |             } catch (e: Exception) { | ||||||
|  |                 print(e.message) | ||||||
|  |                 Toast.makeText(context, "${e.message}", Toast.LENGTH_SHORT).show() | ||||||
|             } |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun updateAccount(u: String, p: String) { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             settingsDataStoreManager.saveUserName(u) | ||||||
|  |             settingsDataStoreManager.savePrivateKey(p) | ||||||
|  |  | ||||||
|             privateKey.value = "******" |             privateKey.value = "******" | ||||||
|  |  | ||||||
|             Global.loggedIn = false |             Global.loggedIn = false | ||||||
|  |  | ||||||
|             val u = userNameFlow.first() |             val u = settingsDataStoreManager.userNameFlow.first() | ||||||
|             val p = privateKeyFlow.first() |             val p = settingsDataStoreManager.privateKeyFlow.first() | ||||||
|  |             val ur = settingsDataStoreManager.urlFlow.first() | ||||||
|  |  | ||||||
|             if (u == "" || p == "") return@launch |             if (u == "" || p == "" || ur == "") return@launch | ||||||
|  |  | ||||||
|             try { |             try { | ||||||
|                 MediaManager.token = AuthManager.fetchToken( |                 mediaManager.token = AuthManager.fetchToken( | ||||||
|                     ApiClient.base, |  | ||||||
|                     u, |                     u, | ||||||
|                     p |                     p | ||||||
|                 )!! |                 )!! | ||||||
|  |  | ||||||
|                 Global.loggedIn = true |                 Global.loggedIn = true | ||||||
|  |                 withContext(Dispatchers.IO) | ||||||
|  |                 { | ||||||
|  |                     (context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096) | ||||||
|  |                     context.abyssService?.downloader?.init() | ||||||
|  |                 } | ||||||
|                 Toast.makeText(context, "Account Updated", Toast.LENGTH_SHORT).show() |                 Toast.makeText(context, "Account Updated", Toast.LENGTH_SHORT).show() | ||||||
|             } catch (e: Exception) { |             } catch (e: Exception) { | ||||||
|                 print(e.message) |                 print(e.message) | ||||||
|   | |||||||
| @@ -0,0 +1,158 @@ | |||||||
|  | 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 com.acitelight.aether.model.DownloadItemState | ||||||
|  | import com.acitelight.aether.service.FetchManager | ||||||
|  | import com.acitelight.aether.service.VideoLibrary | ||||||
|  | import com.tonyodev.fetch2.Download | ||||||
|  | import com.tonyodev.fetch2.FetchListener | ||||||
|  | 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 javax.inject.Inject | ||||||
|  |  | ||||||
|  | @HiltViewModel | ||||||
|  | class TransmissionScreenViewModel @Inject constructor( | ||||||
|  |     val fetchManager: FetchManager, | ||||||
|  |     @ApplicationContext val context: Context, | ||||||
|  |     private val videoLibrary: VideoLibrary | ||||||
|  | ) : ViewModel() { | ||||||
|  |     private val _downloads: SnapshotStateList<DownloadItemState> = mutableStateListOf() | ||||||
|  |     val downloads: SnapshotStateList<DownloadItemState> = _downloads | ||||||
|  |  | ||||||
|  |     // map id -> state object reference (no index bookkeeping) | ||||||
|  |     private val idToState: MutableMap<Int, DownloadItemState> = mutableMapOf() | ||||||
|  |  | ||||||
|  |     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) { | ||||||
|  |             val ii = videoLibrary.classesMap[download.extras.getString("class", "")] | ||||||
|  |                 ?.indexOfFirst { it.id == download.extras.getString("id", "") }!! | ||||||
|  |  | ||||||
|  |             val newi = videoLibrary.classesMap[download.extras.getString("class", "")]!![ii] | ||||||
|  |             videoLibrary.classesMap[download.extras.getString("class", "")]!![ii] = newi.toLocal(context.getExternalFilesDir(null)!!.path) | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |         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) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     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): DownloadItemState { | ||||||
|  |         val filePath = download.file | ||||||
|  |  | ||||||
|  |         return DownloadItemState( | ||||||
|  |             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", "") | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     // UI actions delegated to FetchManager | ||||||
|  |     fun pause(id: Int) = fetchManager.pause(id) | ||||||
|  |     fun resume(id: Int) = fetchManager.resume(id) | ||||||
|  |     fun cancel(id: Int) = fetchManager.cancel(id) | ||||||
|  |     fun delete(id: Int, deleteFile: Boolean = true) { | ||||||
|  |         fetchManager.delete(id) { | ||||||
|  |             viewModelScope.launch(Dispatchers.Main) { removeOnMain(id) } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     override fun onCleared() { | ||||||
|  |         super.onCleared() | ||||||
|  |         fetchManager.removeListener() | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     init { | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             fetchManager.setListener(fetchListener) | ||||||
|  |             withContext(Dispatchers.Main) { | ||||||
|  |                 fetchManager.getAllDownloads { list -> | ||||||
|  |                     _downloads.clear() | ||||||
|  |                     idToState.clear() | ||||||
|  |                     list.sortedBy { it.extras.getString("name", "") }.forEach { d -> | ||||||
|  |                         val s = downloadToState(d) | ||||||
|  |                         _downloads.add(s) | ||||||
|  |                         idToState[s.id] = s | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,8 +1,10 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
|  | import android.content.Context | ||||||
|  | import android.net.Uri | ||||||
|  | import android.widget.Toast | ||||||
| import androidx.annotation.OptIn | import androidx.annotation.OptIn | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.Composable | ||||||
| import androidx.compose.runtime.State |  | ||||||
| import androidx.compose.runtime.getValue | import androidx.compose.runtime.getValue | ||||||
| import androidx.compose.runtime.mutableFloatStateOf | import androidx.compose.runtime.mutableFloatStateOf | ||||||
| import androidx.compose.runtime.mutableIntStateOf | import androidx.compose.runtime.mutableIntStateOf | ||||||
| @@ -13,80 +15,237 @@ import androidx.compose.ui.platform.LocalContext | |||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import androidx.media3.common.MediaItem | import androidx.media3.common.MediaItem | ||||||
|  | import androidx.media3.common.PlaybackException | ||||||
| import androidx.media3.common.Player | import androidx.media3.common.Player | ||||||
| import androidx.media3.common.Player.STATE_READY | import androidx.media3.common.Player.STATE_READY | ||||||
|  | import androidx.media3.common.text.Cue | ||||||
|  | import androidx.media3.common.util.Log | ||||||
| import androidx.media3.common.util.UnstableApi | import androidx.media3.common.util.UnstableApi | ||||||
|  | import androidx.media3.datasource.okhttp.OkHttpDataSource | ||||||
| import androidx.media3.exoplayer.ExoPlayer | import androidx.media3.exoplayer.ExoPlayer | ||||||
| import com.acitelight.aether.Global | import androidx.media3.exoplayer.source.DefaultMediaSourceFactory | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import com.acitelight.aether.model.VideoQueryIndex | import com.acitelight.aether.model.VideoQueryIndex | ||||||
|  | import com.acitelight.aether.model.VideoRecord | ||||||
|  | import com.acitelight.aether.model.VideoRecordDatabase | ||||||
|  | import com.acitelight.aether.service.ApiClient.createOkHttp | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import com.acitelight.aether.service.RecentManager | import com.acitelight.aether.service.RecentManager | ||||||
|  | import com.acitelight.aether.view.formatTime | ||||||
| import com.acitelight.aether.view.hexToString | import com.acitelight.aether.view.hexToString | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import kotlinx.coroutines.CoroutineScope | import kotlinx.coroutines.CoroutineScope | ||||||
| import kotlinx.coroutines.Dispatchers | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.delay | import kotlinx.coroutines.delay | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
|  | import kotlinx.coroutines.withContext | ||||||
|  | import okhttp3.Request | ||||||
|  | import java.io.File | ||||||
|  | import javax.inject.Inject | ||||||
|  | import androidx.core.net.toUri | ||||||
|  | import androidx.media3.common.C | ||||||
|  | import androidx.media3.common.Tracks | ||||||
|  | import androidx.media3.exoplayer.trackselection.DefaultTrackSelector | ||||||
|  |  | ||||||
| class VideoPlayerViewModel() : ViewModel() | @HiltViewModel | ||||||
| { | class VideoPlayerViewModel @Inject constructor( | ||||||
|  |     @ApplicationContext private val context: Context, | ||||||
|  |     val mediaManager: MediaManager, | ||||||
|  |     val recentManager: RecentManager | ||||||
|  | ) : ViewModel() { | ||||||
|     var tabIndex by mutableIntStateOf(0) |     var tabIndex by mutableIntStateOf(0) | ||||||
|     var isPlaying by  mutableStateOf(true) |     var isPlaying by mutableStateOf(true) | ||||||
|     var playProcess by mutableFloatStateOf(0.0f) |     var playProcess by mutableFloatStateOf(0.0f) | ||||||
|     var planeVisibility by mutableStateOf(true) |     var planeVisibility by mutableStateOf(true) | ||||||
|     var isLongPressing by mutableStateOf(false) |     var isLongPressing by mutableStateOf(false) | ||||||
|     var dragging by mutableStateOf(false) |  | ||||||
|  |     // -1 : Not dragging | ||||||
|  |     // 0  : Seek | ||||||
|  |     // 1  : Volume | ||||||
|  |     // 2  : Brightness | ||||||
|  |     var draggingPurpose by mutableIntStateOf(-1) | ||||||
|  |  | ||||||
|     var thumbUp by mutableIntStateOf(0) |     var thumbUp by mutableIntStateOf(0) | ||||||
|     var thumbDown by mutableIntStateOf(0) |     var thumbDown by mutableIntStateOf(0) | ||||||
|     var star by mutableStateOf(false) |     var star by mutableStateOf(false) | ||||||
|  |     var locked by mutableStateOf(false) | ||||||
|     private var _init: Boolean = false; |     private var _init: Boolean = false; | ||||||
|     var startPlaying by mutableStateOf(false) |     var startPlaying by mutableStateOf(false) | ||||||
|     var renderedFirst = false |     var renderedFirst = false | ||||||
|     var video: Video? = null |     var video: Video? = null | ||||||
|  |  | ||||||
|     @Composable |     val dataSourceFactory = OkHttpDataSource.Factory(createOkHttp()) | ||||||
|     fun Init(videoId: String) |     var imageLoader: ImageLoader? = null; | ||||||
|     { |     var brit by mutableFloatStateOf(0.5f) | ||||||
|         if(_init) return; |     val database: VideoRecordDatabase = VideoRecordDatabase.getDatabase(context) | ||||||
|         val context = LocalContext.current |     var cues by mutableStateOf(listOf<Cue>()) | ||||||
|  |  | ||||||
|  |     @OptIn(UnstableApi::class) | ||||||
|  |     fun init(videoId: String) { | ||||||
|  |         if (_init) return; | ||||||
|         val v = videoId.hexToString() |         val v = videoId.hexToString() | ||||||
|  |         imageLoader = ImageLoader.Builder(context) | ||||||
|  |             .components { | ||||||
|  |                 add(OkHttpNetworkFetcherFactory(createOkHttp())) | ||||||
|  |             } | ||||||
|  |             .build() | ||||||
|  |  | ||||||
|         remember { |         viewModelScope.launch { | ||||||
|             viewModelScope.launch { |             video = mediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!! | ||||||
|                 video = MediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!! |             recentManager.pushVideo(context, VideoQueryIndex(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 |  | ||||||
|  |  | ||||||
|                     addListener(object : Player.Listener { |             val subtitleCandidate = video?.getSubtitle()?.trim() | ||||||
|                         override fun onPlaybackStateChanged(playbackState: Int) { |             val subtitleUri = tryResolveSubtitleUri(subtitleCandidate) | ||||||
|                             if (playbackState == STATE_READY) { |  | ||||||
|                                 startPlaying = true |             // decide whether we need network-capable media source factory: | ||||||
|  |             val subtitleIsRemote = subtitleUri?.scheme?.startsWith("http", ignoreCase = true) == true | ||||||
|  |             val videoIsRemote = !video!!.isLocal | ||||||
|  |             val needNetworkFactory = videoIsRemote || subtitleIsRemote | ||||||
|  |             val trackSelector = DefaultTrackSelector(context) | ||||||
|  |  | ||||||
|  |             // build ExoPlayer with or without custom DefaultMediaSourceFactory | ||||||
|  |             val builder = if (needNetworkFactory) | ||||||
|  |                 ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) | ||||||
|  |             else | ||||||
|  |                 ExoPlayer.Builder(context) | ||||||
|  |  | ||||||
|  |             _player = builder.setTrackSelector(trackSelector).build().apply { | ||||||
|  |                 val url = video?.getVideo() ?: "" | ||||||
|  |                 val videoUri = if (video!!.isLocal) Uri.fromFile(File(url)) else url.toUri() | ||||||
|  |  | ||||||
|  |                 val mediaItem: MediaItem = if (subtitleUri != null) { | ||||||
|  |                     // prepare subtitle configuration with guessed mime type | ||||||
|  |                     val subConfig = MediaItem.SubtitleConfiguration.Builder(subtitleUri) | ||||||
|  |                         .setMimeType("text/vtt") | ||||||
|  |                         .build() | ||||||
|  |  | ||||||
|  |                     MediaItem.Builder() | ||||||
|  |                         .setUri(videoUri) | ||||||
|  |                         .setSubtitleConfigurations(listOf(subConfig)) | ||||||
|  |                         .build() | ||||||
|  |                 } else { | ||||||
|  |                     MediaItem.fromUri(videoUri) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 setMediaItem(mediaItem) | ||||||
|  |                 prepare() | ||||||
|  |                 playWhenReady = true | ||||||
|  |  | ||||||
|  |                 addListener(object : Player.Listener { | ||||||
|  |                     override fun onTracksChanged(tracks: Tracks) { | ||||||
|  |                         super.onTracksChanged(tracks) | ||||||
|  |  | ||||||
|  |                         val trackSelector = _player?.trackSelector | ||||||
|  |                         if (trackSelector is DefaultTrackSelector) { | ||||||
|  |                             val parameters = trackSelector.buildUponParameters() | ||||||
|  |                                 .setSelectUndeterminedTextLanguage(true) | ||||||
|  |                                 .build() | ||||||
|  |                             trackSelector.parameters = parameters | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     override fun onPlaybackStateChanged(playbackState: Int) { | ||||||
|  |                         if (playbackState == STATE_READY) { | ||||||
|  |                             startPlaying = true | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     override fun onRenderedFirstFrame() { | ||||||
|  |                         super.onRenderedFirstFrame() | ||||||
|  |                         if(!renderedFirst) | ||||||
|  |                         { | ||||||
|  |                             viewModelScope.launch { | ||||||
|  |                                 val ii = database.userDao().get(video!!.id, video!!.klass) | ||||||
|  |                                 if(ii != null) | ||||||
|  |                                 { | ||||||
|  |                                     _player!!.seekTo(ii.position) | ||||||
|  |                                     Toast.makeText(context, "Recover from ${formatTime(ii.position)} ", Toast.LENGTH_SHORT).show() | ||||||
|  |                                 } | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
|  |                         renderedFirst = true | ||||||
|  |                     } | ||||||
|  |  | ||||||
|                         override fun onRenderedFirstFrame() { |                     override fun onPlayerError(error: PlaybackException) | ||||||
|                             super.onRenderedFirstFrame() |                     { | ||||||
|                             renderedFirst = true |                         print(error.message) | ||||||
|                         } |                     } | ||||||
|                     }) |  | ||||||
|                 } |                     override fun onCues(lcues: MutableList<Cue>) { | ||||||
|                 startListen() |                         cues = lcues | ||||||
|  |                     } | ||||||
|  |                 }) | ||||||
|             } |             } | ||||||
|  |             startListen() | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         _init = true; |         _init = true; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 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 = createOkHttp() | ||||||
|  |  | ||||||
|  |                 var headReq = Request.Builder().url(trimmed).head().build() | ||||||
|  |                 var headResp = try { client.newCall(headReq).execute() } catch (e: 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() | ||||||
|  |  | ||||||
|  |                 var rangeResp = try { client.newCall(rangeReq).execute() } catch (e: Exception) { null } | ||||||
|  |  | ||||||
|  |                 rangeResp?.use { resp -> | ||||||
|  |                     val code = resp.code | ||||||
|  |                     if (code == 206) { | ||||||
|  |                         return@withContext trimmed.toUri() | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     if (code == 200) { | ||||||
|  |                         return@withContext trimmed.toUri() | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     if (code == 404) { | ||||||
|  |                         return@withContext null | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } catch (e: Exception) { | ||||||
|  |                 return@withContext null | ||||||
|  |             } | ||||||
|  |             return@withContext null | ||||||
|  |         } else { | ||||||
|  |             // Local path | ||||||
|  |             val f = File(trimmed) | ||||||
|  |             return@withContext if (f.exists() && f.isFile) Uri.fromFile(f) else null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     @OptIn(UnstableApi::class) |     @OptIn(UnstableApi::class) | ||||||
|     fun startListen() |     fun startListen() { | ||||||
|     { |  | ||||||
|         CoroutineScope(Dispatchers.Main).launch { |         CoroutineScope(Dispatchers.Main).launch { | ||||||
|             while (_player?.isReleased != true) { |             while (_player?.isReleased != true) { | ||||||
|                 val __player = _player!!; |                 val __player = _player!!; | ||||||
| @@ -100,6 +259,10 @@ class VideoPlayerViewModel() : ViewModel() | |||||||
|  |  | ||||||
|     override fun onCleared() { |     override fun onCleared() { | ||||||
|         super.onCleared() |         super.onCleared() | ||||||
|  |         val p = _player!!.currentPosition | ||||||
|         _player?.release() |         _player?.release() | ||||||
|  |         CoroutineScope(Dispatchers.IO).launch { | ||||||
|  |             database.userDao().insert(VideoRecord(video!!.id, video!!.klass, p)) | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,67 +1,128 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
| import android.app.Application | import android.app.Application | ||||||
|  | import android.content.Context | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
| import androidx.compose.runtime.State | import androidx.compose.runtime.State | ||||||
| import androidx.compose.runtime.collectAsState |  | ||||||
| import androidx.compose.runtime.getValue |  | ||||||
| import androidx.compose.runtime.mutableIntStateOf | import androidx.compose.runtime.mutableIntStateOf | ||||||
| import androidx.datastore.preferences.core.stringPreferencesKey | import androidx.compose.runtime.mutableStateListOf | ||||||
| import androidx.lifecycle.AndroidViewModel | import androidx.compose.runtime.mutableStateMapOf | ||||||
|  | import androidx.compose.runtime.mutableStateOf | ||||||
|  | import androidx.compose.runtime.snapshots.SnapshotStateList | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
| import com.acitelight.aether.Global | import com.acitelight.aether.Global | ||||||
| import com.acitelight.aether.dataStore |  | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import com.acitelight.aether.service.ApiClient | import com.acitelight.aether.service.ApiClient.createOkHttp | ||||||
| import com.acitelight.aether.service.AuthManager | import com.acitelight.aether.service.FetchManager | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import kotlinx.coroutines.flow.Flow | import com.acitelight.aether.service.RecentManager | ||||||
| import kotlinx.coroutines.flow.MutableStateFlow | import com.acitelight.aether.service.VideoLibrary | ||||||
| import kotlinx.coroutines.flow.StateFlow | import com.tonyodev.fetch2.Status | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.flow.filter | ||||||
| import kotlinx.coroutines.flow.first | import kotlinx.coroutines.flow.first | ||||||
| import kotlinx.coroutines.flow.map |  | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import java.io.File | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
| 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( | ||||||
|  |     private val fetchManager: FetchManager, | ||||||
|  |     @ApplicationContext val context: Context, | ||||||
|  |     val mediaManager: MediaManager, | ||||||
|  |     val recentManager: RecentManager, | ||||||
|  |     val videoLibrary: VideoLibrary | ||||||
|  | ) : ViewModel() { | ||||||
|     private val _tabIndex = mutableIntStateOf(0) |     private val _tabIndex = mutableIntStateOf(0) | ||||||
|     val tabIndex: State<Int> = _tabIndex |     val tabIndex: State<Int> = _tabIndex | ||||||
|  |     var imageLoader: ImageLoader? = null; | ||||||
|     private val _videos = MutableStateFlow<List<Video>>(emptyList()) |     var menuVisibility = mutableStateOf(false) | ||||||
|     val videos: StateFlow<List<Video>> = _videos |     var searchFilter = mutableStateOf("") | ||||||
|     private val _klasses = MutableStateFlow<List<String>>(emptyList()) |     var doneInit = mutableStateOf(false) | ||||||
|     val klasses: StateFlow<List<String>> = _klasses; |  | ||||||
|  |  | ||||||
|     suspend fun init() { |     suspend fun init() { | ||||||
|         _klasses.value = MediaManager.listVideoKlasses() |         fetchManager.configured.filter { it }.first() | ||||||
|         val p = MediaManager.listVideos(_klasses.value.first()) |  | ||||||
|         _videos.value = p |         if (Global.loggedIn) { | ||||||
|  |             videoLibrary.classes.addAll(mediaManager.listVideoKlasses()) | ||||||
|  |             if(videoLibrary.classes.isEmpty()) | ||||||
|  |                 return | ||||||
|  |  | ||||||
|  |             var i = 0 | ||||||
|  |             for (it in videoLibrary.classes) { | ||||||
|  |                 videoLibrary.updatingMap[i++] = false | ||||||
|  |                 videoLibrary.classesMap[it] = mutableStateListOf<Video>() | ||||||
|  |             } | ||||||
|  |             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 }) | ||||||
|  |                 videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(r) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         else { | ||||||
|  |             videoLibrary.classes.add("Offline") | ||||||
|  |             videoLibrary.updatingMap[0] = true | ||||||
|  |             videoLibrary.classesMap["Offline"] = mutableStateListOf<Video>() | ||||||
|  |  | ||||||
|  |             val downloaded = fetchManager.getAllDownloadsAsync().filter { | ||||||
|  |                 it.status == Status.COMPLETED && it.extras.getString("isComic", "") != "true" | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             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() |         viewModelScope.launch() | ||||||
|         { |         { | ||||||
|             _tabIndex.intValue = index; |             _tabIndex.intValue = index; | ||||||
|             val p = MediaManager.listVideos(_klasses.value[index]) |             if (videoLibrary.updatingMap[index] == true) return@launch | ||||||
|             _videos.value = p |  | ||||||
|  |             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 }) | ||||||
|  |                 videoLibrary.classesMap[videoLibrary.classes[index]]?.addAll(r) | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     suspend fun download(video: Video) { | ||||||
|  |         fetchManager.startVideoDownload(video) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     init { |     init { | ||||||
|         viewModelScope.launch { |         imageLoader = ImageLoader.Builder(context) | ||||||
|  |             .components { | ||||||
|  |                 add(OkHttpNetworkFetcherFactory(createOkHttp())) | ||||||
|  |             } | ||||||
|  |             .build() | ||||||
|  |  | ||||||
|  |         viewModelScope.launch(Dispatchers.IO) { | ||||||
|             init() |             init() | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										74
									
								
								app/src/main/res/drawable/aether_background.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,74 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <vector | ||||||
|  |     android:height="108dp" | ||||||
|  |     android:width="108dp" | ||||||
|  |     android:viewportHeight="108" | ||||||
|  |     android:viewportWidth="108" | ||||||
|  |     xmlns:android="http://schemas.android.com/apk/res/android"> | ||||||
|  |     <path android:fillColor="#3DDC84" | ||||||
|  |           android:pathData="M0,0h108v108h-108z"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M9,0L9,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,0L19,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M29,0L29,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M39,0L39,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M49,0L49,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M59,0L59,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M69,0L69,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M79,0L79,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M89,0L89,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M99,0L99,108" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,9L108,9" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,19L108,19" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,29L108,29" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,39L108,39" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,49L108,49" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,59L108,59" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,69L108,69" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,79L108,79" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,89L108,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M0,99L108,99" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,29L89,29" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,39L89,39" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,49L89,49" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,59L89,59" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,69L89,69" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M19,79L89,79" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M29,19L29,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M39,19L39,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M49,19L49,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M59,19L59,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M69,19L69,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  |     <path android:fillColor="#00000000" android:pathData="M79,19L79,89" | ||||||
|  |           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||||
|  | </vector> | ||||||
							
								
								
									
										5
									
								
								app/src/main/res/mipmap-anydpi-v26/aether.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||||
|  |     <background android:drawable="@drawable/aether_background"/> | ||||||
|  |     <foreground android:drawable="@mipmap/aether_foreground"/> | ||||||
|  | </adaptive-icon> | ||||||
							
								
								
									
										5
									
								
								app/src/main/res/mipmap-anydpi-v26/aether_round.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | |||||||
|  | <?xml version="1.0" encoding="utf-8"?> | ||||||
|  | <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||||
|  |     <background android:drawable="@drawable/aether_background"/> | ||||||
|  |     <foreground android:drawable="@mipmap/aether_foreground"/> | ||||||
|  | </adaptive-icon> | ||||||
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-hdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-hdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 5.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-hdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-mdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-mdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-mdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xhdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xhdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xhdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.0 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxhdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxhdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 29 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxhdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 9.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxxhdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 10 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxxhdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 56 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxxhdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 17 KiB | 
| @@ -3,4 +3,7 @@ plugins { | |||||||
|     alias(libs.plugins.android.application) apply false |     alias(libs.plugins.android.application) apply false | ||||||
|     alias(libs.plugins.kotlin.android) apply false |     alias(libs.plugins.kotlin.android) apply false | ||||||
|     alias(libs.plugins.kotlin.compose) apply false |     alias(libs.plugins.kotlin.compose) apply false | ||||||
|  |  | ||||||
|  |     alias(libs.plugins.hilt.android) apply false | ||||||
|  |     alias(libs.plugins.ksp) apply false | ||||||
| } | } | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| [versions] | [versions] | ||||||
| agp = "8.12.1" | agp = "8.13.0" | ||||||
|  | ariaCompiler = "latest" | ||||||
| bcprovJdk15on = "1.70" | bcprovJdk15on = "1.70" | ||||||
| bcprovJdk18on = "1.81" | bcprovJdk18on = "1.81" | ||||||
| coilCompose = "3.3.0" | coilCompose = "3.3.0" | ||||||
| @@ -7,27 +8,35 @@ coilNetworkOkhttp = "3.3.0" | |||||||
| converterGson = "3.0.0" | converterGson = "3.0.0" | ||||||
| datastorePreferences = "1.1.7" | datastorePreferences = "1.1.7" | ||||||
| exoplayerplus = "0.2.0" | exoplayerplus = "0.2.0" | ||||||
|  | fetch2 = "3.4.1" | ||||||
|  | fetch2okhttp = "3.4.1" | ||||||
| gson = "2.13.1" | gson = "2.13.1" | ||||||
| kotlin = "2.2.10" | kotlin = "2.2.20" | ||||||
| coreKtx = "1.17.0" | coreKtx = "1.17.0" | ||||||
| junit = "4.13.2" | junit = "4.13.2" | ||||||
| junitVersion = "1.3.0" | junitVersion = "1.3.0" | ||||||
| espressoCore = "3.7.0" | espressoCore = "3.7.0" | ||||||
| kotlinxSerializationJson = "1.9.0" | kotlinxSerializationJson = "1.9.0" | ||||||
| kotlinxSerializationJsonVersion = "1.9.0" | lifecycleRuntimeKtx = "2.9.3" | ||||||
| lifecycleRuntimeKtx = "2.9.2" | activityCompose = "1.11.0" | ||||||
| activityCompose = "1.10.1" | composeBom = "2025.09.00" | ||||||
| composeBom = "2025.08.00" |  | ||||||
| media3Common = "1.8.0" | media3Common = "1.8.0" | ||||||
| media3Exoplayer = "1.8.0" | media3Exoplayer = "1.8.0" | ||||||
|  | media3ExoplayerFfmpeg = "1.8.0" | ||||||
| media3Ui = "1.8.0" | media3Ui = "1.8.0" | ||||||
| navigationCompose = "2.9.3" | navigationCompose = "2.9.4" | ||||||
| okhttp = "5.1.0" | okhttp = "5.1.0" | ||||||
| retrofit = "3.0.0" | retrofit = "3.0.0" | ||||||
| retrofit2KotlinxSerializationConverter = "1.0.0" | retrofit2KotlinxSerializationConverter = "1.0.0" | ||||||
| retrofitVersion = "3.0.0" | media3DatasourceOkhttp = "1.8.0" | ||||||
| tinkAndroid = "1.18.0" | roomCompiler = "2.8.0" | ||||||
| tweetnaclJava = "1.0.0" | roomKtx = "2.8.0" | ||||||
|  | roomRuntime = "2.8.0" | ||||||
|  |  | ||||||
|  | ksp = "2.1.21-2.0.2" | ||||||
|  | hilt = "2.57.1" | ||||||
|  | hilt-navigation-compose = "1.3.0" | ||||||
|  | composeMaterialCore = "1.5.1" | ||||||
|  |  | ||||||
| [libraries] | [libraries] | ||||||
| androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } | ||||||
| @@ -37,11 +46,16 @@ androidx-media3-common = { module = "androidx.media3:media3-common", version.ref | |||||||
| androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } | androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } | ||||||
| androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" } | androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" } | ||||||
| androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } | androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } | ||||||
|  | androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" } | ||||||
|  | androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" } | ||||||
|  | androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } | ||||||
|  | aria-compiler = { module = "com.arialyy.aria:aria-compiler", version.ref = "ariaCompiler" } | ||||||
| bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bcprovJdk15on" } | bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bcprovJdk15on" } | ||||||
| bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprovJdk18on" } |  | ||||||
| coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } | coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } | ||||||
| coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" } | coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" } | ||||||
| converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } | converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } | ||||||
|  | fetch2 = { module = "com.github.tonyofrancis.Fetch:fetch2", version.ref = "fetch2" } | ||||||
|  | fetch2okhttp = { module = "com.github.tonyofrancis.Fetch:fetch2okhttp", version.ref = "fetch2okhttp" } | ||||||
| gson = { module = "com.google.code.gson:gson", version.ref = "gson" } | gson = { module = "com.google.code.gson:gson", version.ref = "gson" } | ||||||
| junit = { group = "junit", name = "junit", version.ref = "junit" } | junit = { group = "junit", name = "junit", version.ref = "junit" } | ||||||
| androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } | ||||||
| @@ -57,16 +71,20 @@ 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-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } | ||||||
| androidx-material3 = { group = "androidx.compose.material3", name = "material3" } | androidx-material3 = { group = "androidx.compose.material3", name = "material3" } | ||||||
| kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } | ||||||
| kotlinx-serialization-json-v163 = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJsonVersion" } |  | ||||||
| okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } | ||||||
| retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } | 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" } | 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" } | androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" } | ||||||
| tweetnacl-java = { module = "com.github.InstantWebP2P:tweetnacl-java", version.ref = "tweetnaclJava" } |  | ||||||
|  | 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" } | ||||||
|  |  | ||||||
| [plugins] | [plugins] | ||||||
| android-application = { id = "com.android.application", version.ref = "agp" } | android-application = { id = "com.android.application", version.ref = "agp" } | ||||||
| kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } | ||||||
| kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } | ||||||
|  |  | ||||||
|  | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } | ||||||
|  | hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } | ||||||
| @@ -16,6 +16,9 @@ dependencyResolutionManagement { | |||||||
|     repositories { |     repositories { | ||||||
|         google() |         google() | ||||||
|         mavenCentral() |         mavenCentral() | ||||||
|  |         maven { | ||||||
|  |             url = uri("https://jitpack.io") | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||