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 | ||||
| *.apk | ||||
| output-metadata.json | ||||
|  | ||||
| release/ | ||||
| # IntelliJ | ||||
| *.iml | ||||
| .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🚀_ | ||||
|  | ||||
| <img src="aether_clip.png" width="25%" alt="Logo"> | ||||
| </div> | ||||
|  | ||||
| <br/> | ||||
| <br/> | ||||
| <br/> | ||||
|  | ||||
| ## Development background | ||||
|  | ||||
| - 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] Optimize API call logic, do not create crashes | ||||
| - [x] Fix the issue of freezing when entering the client without configuring the private key | ||||
| - [ ] Replace Android robot icon with custom design | ||||
| - [ ] Configure server baseURL in client settings | ||||
| - [x] Replace Android robot icon with custom design | ||||
| - [x] Configure server baseURL in client settings | ||||
| - [ ] Implement proper access control for directory queries | ||||
|  | ||||
| ### Medium Priority | ||||
| - [ ] Increase minHeight for video playback | ||||
| - [ ] Add top bar with title and back button in full-screen mode | ||||
| - [ ] Optimize data transfer system | ||||
| - [ ] Improve manga/comic page display | ||||
| - [x] Increase minHeight for video playback | ||||
| - [x] Add top bar with title and back button in full-screen mode | ||||
| - [x] Optimize data transfer system | ||||
| - [x] Improve manga/comic page display | ||||
|  | ||||
| ### Future | ||||
| - [ ] (Prospective) Implement search functionality | ||||
							
								
								
									
										
											BIN
										
									
								
								aether.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 616 KiB | 
							
								
								
									
										
											BIN
										
									
								
								aether_clip.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 348 KiB | 
| @@ -3,6 +3,9 @@ plugins { | ||||
|     alias(libs.plugins.kotlin.android) | ||||
|     alias(libs.plugins.kotlin.compose) | ||||
|     kotlin("plugin.serialization") version "1.9.0" | ||||
|  | ||||
|     alias(libs.plugins.ksp) | ||||
|     alias(libs.plugins.hilt.android) | ||||
| } | ||||
|  | ||||
| android { | ||||
| @@ -26,14 +29,16 @@ android { | ||||
|                 getDefaultProguardFile("proguard-android-optimize.txt"), | ||||
|                 "proguard-rules.pro" | ||||
|             ) | ||||
|             signingConfig = signingConfigs.getByName("debug") | ||||
|         } | ||||
|     } | ||||
|     compileOptions { | ||||
|         sourceCompatibility = JavaVersion.VERSION_11 | ||||
|         targetCompatibility = JavaVersion.VERSION_11 | ||||
|         sourceCompatibility = JavaVersion.VERSION_21 | ||||
|         targetCompatibility = JavaVersion.VERSION_21 | ||||
|     } | ||||
|     kotlinOptions { | ||||
|         jvmTarget = "11" | ||||
|         jvmTarget = "21" | ||||
|         freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode") | ||||
|     } | ||||
|     buildFeatures { | ||||
|         compose = true | ||||
| @@ -41,10 +46,23 @@ android { | ||||
| } | ||||
|  | ||||
| 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.bcprov.jdk15on) | ||||
|     implementation(libs.converter.gson) | ||||
|     implementation(libs.gson) | ||||
|  | ||||
|     implementation(libs.androidx.media3.exoplayer) | ||||
|     implementation(libs.androidx.media3.ui) | ||||
|     implementation(libs.androidx.media3.common) | ||||
| @@ -67,6 +85,7 @@ dependencies { | ||||
|     implementation(libs.androidx.ui.graphics) | ||||
|     implementation(libs.androidx.ui.tooling.preview) | ||||
|     implementation(libs.androidx.material3) | ||||
|     implementation(libs.androidx.media3.datasource.okhttp) | ||||
|     testImplementation(libs.junit) | ||||
|     androidTestImplementation(libs.androidx.junit) | ||||
|     androidTestImplementation(libs.androidx.espresso.core) | ||||
|   | ||||
| @@ -12,22 +12,31 @@ | ||||
|         android:usesCleartextTraffic="true" | ||||
|         android:dataExtractionRules="@xml/data_extraction_rules" | ||||
|         android:fullBackupContent="@xml/backup_rules" | ||||
|         android:icon="@mipmap/ic_launcher" | ||||
|         android:icon="@mipmap/aether" | ||||
|         android:label="@string/app_name" | ||||
|         android:roundIcon="@mipmap/ic_launcher_round" | ||||
|         android:roundIcon="@mipmap/aether_round" | ||||
|         android:supportsRtl="true" | ||||
|         android:theme="@style/Theme.Aether" | ||||
|         android:name=".AetherApp"> | ||||
|         <activity | ||||
|             android:name=".MainActivity" | ||||
|             android:exported="true" | ||||
|             android:label="@string/app_name" | ||||
|             android:theme="@style/Theme.Aether"> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.MAIN" /> | ||||
|  | ||||
|                 <category android:name="android.intent.category.LAUNCHER" /> | ||||
|             </intent-filter> | ||||
|         </activity> | ||||
|  | ||||
|         <activity | ||||
|             android:name=".MainScreenActivity" | ||||
|             android:exported="true" | ||||
|             android:theme="@style/Theme.Aether"> | ||||
|         </activity> | ||||
|  | ||||
|         <service | ||||
|             android:name=".AbyssService" | ||||
|             android:enabled="true" | ||||
|             android:exported="false" /> | ||||
|     </application> | ||||
| </manifest> | ||||
							
								
								
									
										
											BIN
										
									
								
								app/src/main/aether-playstore.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 163 KiB | 
							
								
								
									
										49
									
								
								app/src/main/java/com/acitelight/aether/AbyssService.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,49 @@ | ||||
| package com.acitelight.aether | ||||
|  | ||||
| import android.app.Service | ||||
| import android.content.Intent | ||||
| import android.os.Binder | ||||
| import android.os.IBinder | ||||
| import com.acitelight.aether.service.AbyssTunnelProxy | ||||
| import com.acitelight.aether.service.FetchManager | ||||
| import com.acitelight.aether.service.SettingsDataStoreManager | ||||
| import dagger.hilt.android.AndroidEntryPoint | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.Job | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import kotlinx.coroutines.flow.asStateFlow | ||||
| import kotlinx.coroutines.flow.update | ||||
| import kotlinx.coroutines.launch | ||||
| import javax.inject.Inject | ||||
|  | ||||
| @AndroidEntryPoint | ||||
| class AbyssService: Service() { | ||||
|     @Inject | ||||
|     lateinit var proxy: AbyssTunnelProxy | ||||
|     @Inject | ||||
|     lateinit var downloader: FetchManager | ||||
|  | ||||
|     private val binder = AbyssServiceBinder() | ||||
|     private val _isInitialized = MutableStateFlow(false) | ||||
|     val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow() | ||||
|     private val serviceScope = CoroutineScope(Dispatchers.IO + Job()) | ||||
|  | ||||
|     inner class AbyssServiceBinder : Binder() { | ||||
|         fun getService(): AbyssService = this@AbyssService | ||||
|     } | ||||
|  | ||||
|     override fun onBind(intent: Intent?): IBinder { | ||||
|         return binder | ||||
|     } | ||||
|  | ||||
|     override fun onCreate() { | ||||
|         super.onCreate() | ||||
|  | ||||
|         serviceScope.launch { | ||||
|             _isInitialized.update { true } | ||||
|             proxy.start() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,15 +1,51 @@ | ||||
| package com.acitelight.aether | ||||
|  | ||||
| import android.app.Application | ||||
| import android.content.ComponentName | ||||
| import android.content.Context | ||||
| import android.content.Intent | ||||
| import android.content.ServiceConnection | ||||
| import android.os.IBinder | ||||
| import androidx.datastore.core.DataStore | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| import androidx.datastore.preferences.preferencesDataStore | ||||
| import dagger.Module | ||||
| import dagger.Provides | ||||
| import dagger.hilt.InstallIn | ||||
| import dagger.hilt.android.HiltAndroidApp | ||||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import dagger.hilt.components.SingletonComponent | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import javax.inject.Singleton | ||||
|  | ||||
| val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "configure") | ||||
|  | ||||
| @HiltAndroidApp | ||||
| class AetherApp : Application() { | ||||
|     var abyssService: AbyssService? = null | ||||
|     var isServiceBound = false | ||||
|         private set | ||||
|  | ||||
|     val isServiceInitialized: StateFlow<Boolean>? | ||||
|         get() = abyssService?.isInitialized | ||||
|  | ||||
|     private val serviceConnection = object : ServiceConnection { | ||||
|         override fun onServiceConnected(name: ComponentName?, service: IBinder?) { | ||||
|             val binder = service as AbyssService.AbyssServiceBinder | ||||
|             abyssService = binder.getService() | ||||
|             isServiceBound = true | ||||
|         } | ||||
|  | ||||
|         override fun onServiceDisconnected(name: ComponentName?) { | ||||
|             isServiceBound = false | ||||
|             abyssService = null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     override fun onCreate() { | ||||
|         super.onCreate() | ||||
|  | ||||
|         val intent = Intent(this, AbyssService::class.java) | ||||
|         bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) | ||||
|     } | ||||
| } | ||||
| @@ -8,4 +8,17 @@ import com.acitelight.aether.model.Video | ||||
| object Global { | ||||
|     var loggedIn by mutableStateOf(false) | ||||
|     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 | ||||
|  | ||||
| import android.app.Activity | ||||
| import android.content.Intent | ||||
| import androidx.compose.material.icons.Icons | ||||
| import android.graphics.drawable.Icon | ||||
| import android.net.http.SslCertificate.saveState | ||||
| import android.os.Bundle | ||||
| import android.view.WindowManager | ||||
| import androidx.activity.ComponentActivity | ||||
| import androidx.activity.compose.setContent | ||||
| import androidx.activity.enableEdgeToEdge | ||||
| import androidx.compose.animation.AnimatedVisibility | ||||
| import androidx.compose.animation.slideInVertically | ||||
| import androidx.compose.animation.slideOutVertically | ||||
| import androidx.compose.foundation.background | ||||
| import androidx.compose.foundation.isSystemInDarkTheme | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.heightIn | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.material.icons.automirrored.filled.CompareArrows | ||||
| import androidx.compose.material.icons.filled.* | ||||
| import androidx.compose.material3.Card | ||||
| import androidx.compose.material3.CardColors | ||||
| import androidx.compose.material3.CardDefaults | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.NavigationBar | ||||
| import androidx.compose.material3.NavigationBarItem | ||||
| import androidx.compose.material3.Scaffold | ||||
| @@ -30,6 +41,7 @@ import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.graphics.vector.ImageVector | ||||
| import androidx.compose.ui.platform.LocalView | ||||
| import androidx.compose.ui.res.painterResource | ||||
| @@ -38,6 +50,7 @@ import androidx.compose.ui.unit.dp | ||||
| import androidx.core.view.WindowCompat | ||||
| import androidx.core.view.WindowInsetsCompat | ||||
| import androidx.core.view.WindowInsetsControllerCompat | ||||
| import androidx.lifecycle.lifecycleScope | ||||
| import androidx.navigation.NavController | ||||
| import androidx.navigation.NavGraph.Companion.findStartDestination | ||||
| import androidx.navigation.NavType | ||||
| @@ -47,15 +60,46 @@ import androidx.navigation.compose.composable | ||||
| import androidx.navigation.compose.currentBackStackEntryAsState | ||||
| import androidx.navigation.compose.rememberNavController | ||||
| 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.HomeScreen | ||||
| import com.acitelight.aether.view.MeScreen | ||||
| import com.acitelight.aether.view.TransmissionScreen | ||||
| import com.acitelight.aether.view.VideoPlayer | ||||
| 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() { | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|  | ||||
|         val app = application as AetherApp | ||||
|  | ||||
|         lifecycleScope.launch { | ||||
|             app.isServiceInitialized?.filter { it }?.first() | ||||
|             val intent = Intent(this@MainActivity, MainScreenActivity::class.java) | ||||
|             startActivity(intent) | ||||
|             finish() | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @AndroidEntryPoint | ||||
| class MainScreenActivity : ComponentActivity() { | ||||
|     override fun onCreate(savedInstanceState: Bundle?) { | ||||
|         super.onCreate(savedInstanceState) | ||||
|         window.attributes = window.attributes.apply { | ||||
|             screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE | ||||
|         } | ||||
|         enableEdgeToEdge() | ||||
|         WindowCompat.setDecorFitsSystemWindows(window, false) | ||||
|         setContent { | ||||
| @@ -94,6 +138,8 @@ fun AppNavigation() { | ||||
|  | ||||
|     val hideBottomBarRoutes = listOf( | ||||
|         Screen.VideoPlayer.route, | ||||
|         Screen.ComicGrid.route, | ||||
|         Screen.ComicPage.route | ||||
|     ) | ||||
|     val shouldShowBottomBar = currentRoute !in hideBottomBarRoutes | ||||
|  | ||||
| @@ -112,21 +158,27 @@ fun AppNavigation() { | ||||
|     ) { innerPadding -> | ||||
|         NavHost( | ||||
|             navController = navController, | ||||
|             startDestination = Screen.Home.route, | ||||
|             startDestination = Screen.Me.route, | ||||
|             modifier = if(shouldShowBottomBar)Modifier.padding(innerPadding) else Modifier.padding(0.dp) | ||||
|         ) { | ||||
|             composable(Screen.Home.route) { | ||||
|                 HomeScreen(navController = navController) | ||||
|                 CardPage(title = "Home") { | ||||
|                     HomeScreen(navController = navController) | ||||
|                 } | ||||
|             } | ||||
|             composable(Screen.Video.route) { | ||||
|                 VideoScreen(navController = navController) | ||||
|             } | ||||
|             composable(Screen.Comic.route) { | ||||
|                 ComicScreen() | ||||
|                 CardPage(title = "Comic") { | ||||
|                     ComicScreen(navController = navController) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             composable(Screen.Transmission.route) { | ||||
|                 // ComicScreen() | ||||
|                 CardPage(title = "Tasks") { | ||||
|                     TransmissionScreen(navigator = navController) | ||||
|                 } | ||||
|             } | ||||
|             composable(Screen.Me.route) { | ||||
|                 MeScreen(); | ||||
| @@ -142,6 +194,30 @@ fun AppNavigation() { | ||||
|                     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.Me | ||||
|     ) else  listOf( | ||||
|         Screen.Home, | ||||
|         Screen.Video, | ||||
|         Screen.Transmission, | ||||
|         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) { | ||||
|     data object Home : Screen("home_route", Icons.Filled.Home, "Home") | ||||
|     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") | ||||
|     data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me") | ||||
|     data object VideoPlayer : Screen("video_player_route/{videoId}", Icons.Filled.PlayArrow, "VideoPlayer") | ||||
|     data object ComicGrid : Screen("comic_grid_route/{comicId}", Icons.Filled.PlayArrow, "ComicGrid") | ||||
|     data object ComicPage : Screen("comic_page_route/{comicId}/{page}", Icons.Filled.PlayArrow, "ComicPage") | ||||
| } | ||||
| @@ -1,66 +0,0 @@ | ||||
| import android.content.Context | ||||
| import android.view.ViewGroup | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.DisposableEffect | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.viewinterop.AndroidView | ||||
| import androidx.media3.common.MediaItem | ||||
| import androidx.media3.common.util.UnstableApi | ||||
| import androidx.media3.datasource.ByteArrayDataSource | ||||
| import androidx.media3.datasource.DataSource | ||||
| import androidx.media3.exoplayer.ExoPlayer | ||||
| import androidx.media3.exoplayer.source.ProgressiveMediaSource | ||||
| import androidx.media3.ui.PlayerView | ||||
|  | ||||
|  | ||||
| @androidx.annotation.OptIn(UnstableApi::class) | ||||
| @Composable | ||||
| private fun InMemoryVideoPlayer( | ||||
|     modifier: Modifier = Modifier, | ||||
|     videoData: ByteArray | ||||
| ) { | ||||
|     val context = LocalContext.current | ||||
|  | ||||
|     val exoPlayer = remember(context, videoData) { | ||||
|         createExoPlayer(context, videoData) | ||||
|     } | ||||
|  | ||||
|     DisposableEffect(Unit) { | ||||
|         onDispose { | ||||
|             exoPlayer.release() | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     AndroidView( | ||||
|         modifier = modifier, | ||||
|         factory = { | ||||
|             PlayerView(it).apply { | ||||
|                 player = exoPlayer | ||||
|                 layoutParams = ViewGroup.LayoutParams( | ||||
|                     ViewGroup.LayoutParams.MATCH_PARENT, | ||||
|                     ViewGroup.LayoutParams.MATCH_PARENT | ||||
|                 ) | ||||
|             } | ||||
|         } | ||||
|     ) | ||||
| } | ||||
|  | ||||
| @androidx.annotation.OptIn(UnstableApi::class) | ||||
| private fun createExoPlayer(context: Context, videoData: ByteArray): ExoPlayer { | ||||
|     val byteArrayDataSource = ByteArrayDataSource(videoData) | ||||
|  | ||||
|     val factory = DataSource.Factory { | ||||
|         byteArrayDataSource | ||||
|     } | ||||
|  | ||||
|     val mediaSource = ProgressiveMediaSource.Factory(factory) | ||||
|         .createMediaSource(MediaItem.fromUri("data://local/video.mp4")) | ||||
|  | ||||
|     return ExoPlayer.Builder(context).build().apply { | ||||
|         setMediaSource(mediaSource) | ||||
|         prepare() | ||||
|         playWhenReady = false | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,74 @@ | ||||
| package com.acitelight.aether.helper | ||||
|  | ||||
| import com.acitelight.aether.model.Video | ||||
| import java.text.Collator | ||||
| import java.util.Locale | ||||
|  | ||||
| fun MutableList<Video>.insertInNaturalOrder(n: Video) { | ||||
|     // Windows sorting is locale-sensitive. Use the default locale. | ||||
|     val collator = Collator.getInstance(Locale.getDefault()) | ||||
|  | ||||
|     val naturalComparator = Comparator<String> { s1, s2 -> | ||||
|         val naturalOrderComparator = fun(str1: String, str2: String): Int { | ||||
|             // Function to compare segments (numeric vs. non-numeric) | ||||
|             val compareSegments = fun(segment1: String, segment2: String, isNumeric: Boolean): Int { | ||||
|                 return if (isNumeric) { | ||||
|                     val num1 = segment1.toLongOrNull() ?: 0 | ||||
|                     val num2 = segment2.toLongOrNull() ?: 0 | ||||
|                     num1.compareTo(num2) | ||||
|                 } else { | ||||
|                     collator.compare(segment1, segment2) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Regex to split string into numeric and non-numeric parts | ||||
|             val regex = "(\\d+)|(\\D+)".toRegex() | ||||
|             val matches1 = regex.findAll(str1).toList() | ||||
|             val matches2 = regex.findAll(str2).toList() | ||||
|  | ||||
|             var i = 0 | ||||
|             while (i < matches1.size && i < matches2.size) { | ||||
|                 val match1 = matches1[i] | ||||
|                 val match2 = matches2[i] | ||||
|  | ||||
|                 val isNumeric1 = match1.groupValues[1].isNotEmpty() | ||||
|                 val isNumeric2 = match2.groupValues[1].isNotEmpty() | ||||
|  | ||||
|                 when { | ||||
|                     isNumeric1 && isNumeric2 -> { | ||||
|                         val result = compareSegments(match1.value, match2.value, true) | ||||
|                         if (result != 0) return result | ||||
|                     } | ||||
|                     !isNumeric1 && !isNumeric2 -> { | ||||
|                         val result = compareSegments(match1.value, match2.value, false) | ||||
|                         if (result != 0) return result | ||||
|                     } | ||||
|                     isNumeric1 -> return -1 // Numeric part comes before non-numeric | ||||
|                     isNumeric2 -> return 1 | ||||
|                 } | ||||
|                 i++ | ||||
|             } | ||||
|  | ||||
|             // If one string is a prefix of the other, the shorter one comes first | ||||
|             return str1.length.compareTo(str2.length) | ||||
|         } | ||||
|  | ||||
|         naturalOrderComparator(s1, s2) | ||||
|     } | ||||
|  | ||||
|     var inserted = false | ||||
|  | ||||
|     // Find the correct insertion point | ||||
|     for (i in this.indices) { | ||||
|         if (naturalComparator.compare(n.video.name, this[i].video.name) <= 0) { | ||||
|             this.add(i, n) | ||||
|             inserted = true | ||||
|             break | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // If it's the largest, add to the end | ||||
|     if (!inserted) { | ||||
|         this.add(n) | ||||
|     } | ||||
| } | ||||
| @@ -1,8 +1,69 @@ | ||||
| package com.acitelight.aether.model | ||||
|  | ||||
| data class Comic( | ||||
|     val comic_name: String, | ||||
|     val page_count: Int, | ||||
|     val bookmarks: List<BookMark>, | ||||
|     val pages: List<String> | ||||
| import com.acitelight.aether.service.ApiClient | ||||
|  | ||||
| class Comic( | ||||
|     val comic: ComicResponse, | ||||
|     val id: String, | ||||
|     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 | ||||
|  | ||||
| import kotlinx.serialization.Serializable | ||||
|  | ||||
|  | ||||
| @Serializable | ||||
| data class Comment( | ||||
|     val content: 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 | ||||
|  | ||||
| data class KeyImage( | ||||
|     val name: String, | ||||
|     val url: String, | ||||
|     val key: String | ||||
| ) | ||||
|   | ||||
| @@ -1,29 +1,66 @@ | ||||
| package com.acitelight.aether.model | ||||
|  | ||||
| import com.acitelight.aether.service.ApiClient | ||||
| import kotlinx.serialization.Serializable | ||||
| import java.security.KeyPair | ||||
|  | ||||
| class Video constructor( | ||||
|  | ||||
| @Serializable | ||||
| class Video( | ||||
|     val isLocal: Boolean, | ||||
|     val localBase: String, | ||||
|     val klass: String, | ||||
|     val id: String, | ||||
|     val token: String, | ||||
|     val video: VideoResponse | ||||
|     ){ | ||||
|     fun getCover(): String | ||||
|     { | ||||
|         return "${ApiClient.base}api/video/$klass/$id/cover?token=$token" | ||||
| ) { | ||||
|     fun getCover(): String { | ||||
|         return if (isLocal) | ||||
|             "$localBase/videos/$klass/$id/cover.jpg" | ||||
|         else | ||||
|             "${ApiClient.getBase()}api/video/$klass/$id/cover?token=$token" | ||||
|     } | ||||
|  | ||||
|     fun getVideo(): String | ||||
|     { | ||||
|         return "${ApiClient.base}api/video/$klass/$id/av?token=$token" | ||||
|     fun getVideo(): String { | ||||
|         return if (isLocal) | ||||
|             "$localBase/videos/$klass/$id/video.mp4" | ||||
|         else | ||||
|             "${ApiClient.getBase()}api/video/$klass/$id/av?token=$token" | ||||
|     } | ||||
|  | ||||
|     fun getGallery(): List<KeyImage> | ||||
|     { | ||||
|         return video.gallery.map{ | ||||
|             KeyImage(url = "${ApiClient.base}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it") | ||||
|     fun getSubtitle(): String { | ||||
|         return if (isLocal) | ||||
|             "$localBase/videos/$klass/$id/subtitle.vtt" | ||||
|         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 | ||||
|  | ||||
| import kotlinx.serialization.Serializable | ||||
|  | ||||
| @Serializable | ||||
| data class VideoResponse( | ||||
|     val name: String, | ||||
|     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 | ||||
|  | ||||
| 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 kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.withContext | ||||
| import kotlinx.serialization.json.Json | ||||
| import okhttp3.ConnectionSpec | ||||
| import okhttp3.EventListener | ||||
| import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | ||||
| import okhttp3.MediaType.Companion.toMediaType | ||||
| import okhttp3.OkHttpClient | ||||
| import retrofit2.Retrofit | ||||
| 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 { | ||||
|     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 { | ||||
|         ignoreUnknownKeys = true | ||||
|     } | ||||
|  | ||||
|     private val retrofit = Retrofit.Builder() | ||||
|         .baseUrl(base) | ||||
|         .addConverterFactory(GsonConverterFactory.create()) | ||||
|         .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) | ||||
|         .build() | ||||
|     fun replaceAbyssProtocol(uri: String): String { | ||||
|         return uri.replaceFirst("^abyss://".toRegex(), "https://") | ||||
|     } | ||||
|  | ||||
|     val api: ApiInterface by lazy { | ||||
|         retrofit.create(ApiInterface::class.java) | ||||
|     private val dnsEventListener = object : EventListener() { | ||||
|         override fun dnsEnd(call: okhttp3.Call, domainName: String, inetAddressList: List<InetAddress>) { | ||||
|             super.dnsEnd(call, domainName, inetAddressList) | ||||
|             val ipAddresses = inetAddressList.joinToString(", ") { it.hostAddress ?: "" } | ||||
|             Log.d("OkHttp_DNS", "Domain '$domainName' resolved to IPs: [$ipAddresses]") | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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 | ||||
|  | ||||
| import com.acitelight.aether.model.BookMark | ||||
| import com.acitelight.aether.model.ChallengeResponse | ||||
| import com.acitelight.aether.model.Comic | ||||
| import com.acitelight.aether.model.ComicResponse | ||||
| import com.acitelight.aether.model.VideoResponse | ||||
| import okhttp3.Response | ||||
| import okhttp3.ResponseBody | ||||
| import retrofit2.http.Body | ||||
| import retrofit2.http.GET | ||||
| @@ -28,19 +30,23 @@ interface ApiInterface { | ||||
|         @Query("token") token: String | ||||
|     ): VideoResponse | ||||
|  | ||||
|     @GET("api/video/{klass}/{id}/nv") | ||||
|     @Streaming | ||||
|     suspend fun getNailVideo( | ||||
|     @POST("api/video/{klass}/bulkquery") | ||||
|     suspend fun queryVideoBulk( | ||||
|         @Path("klass") klass: String, | ||||
|         @Path("id") id: String, | ||||
|         @Body() id: List<String>, | ||||
|         @Query("token") token: String | ||||
|     ): ResponseBody | ||||
|     ): List<VideoResponse> | ||||
|  | ||||
|     @GET("api/image/collections") | ||||
|     suspend fun getComicCollections(): List<String> | ||||
|     @GET("api/image/meta") | ||||
|     suspend fun queryComicInfo(@Query("collection") collection: String): Comic | ||||
|     @GET("api/image") | ||||
|     suspend fun getComics(@Query("token") token: String): List<String> | ||||
|     @GET("api/image/{id}") | ||||
|     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}") | ||||
|     suspend fun getChallenge( | ||||
| @@ -52,4 +58,7 @@ interface ApiInterface { | ||||
|         @Path("user") user: String, | ||||
|         @Body challengeResponse: ChallengeResponse | ||||
|     ): ResponseBody | ||||
|  | ||||
|     @GET("api/abyss") | ||||
|     suspend fun hello(): ResponseBody | ||||
| } | ||||
|   | ||||
| @@ -1,23 +1,34 @@ | ||||
| package com.acitelight.aether.service | ||||
|  | ||||
| import android.util.Base64 | ||||
| import android.util.Log | ||||
| import com.acitelight.aether.model.ChallengeResponse | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.runBlocking | ||||
| import kotlinx.coroutines.withContext | ||||
| import okhttp3.Call | ||||
| import okhttp3.EventListener | ||||
| import okhttp3.Handshake | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters | ||||
| import org.bouncycastle.crypto.signers.Ed25519Signer | ||||
| import java.io.IOException | ||||
| import java.lang.reflect.Proxy | ||||
| import java.net.InetSocketAddress | ||||
| import java.security.PrivateKey | ||||
| import java.security.Signature | ||||
|  | ||||
| object AuthManager { | ||||
|     suspend fun fetchToken(baseUrl: String, username: String, privateKey: String): String? { | ||||
|     suspend fun fetchToken(username: String, privateKey: String): String? { | ||||
|         val api = ApiClient.api | ||||
|         var challengeBase64 = "" | ||||
|  | ||||
|         try{ | ||||
|             challengeBase64 = api.getChallenge(username).string() | ||||
|             challengeBase64 = api!!.getChallenge(username).string() | ||||
|         }catch (e: Exception) | ||||
|         { | ||||
|             print(e.message) | ||||
|             return null | ||||
|         } | ||||
|  | ||||
|         val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64)) | ||||
| @@ -44,4 +55,14 @@ object AuthManager { | ||||
|         val signature = signer.generateSignature() | ||||
|         return Base64.encodeToString(signature, Base64.NO_WRAP) | ||||
|     } | ||||
|  | ||||
|     fun signChallengeByte(privateKey: ByteArray, data: ByteArray): ByteArray //64 Byte | ||||
|     { | ||||
|         val privateKeyParams = Ed25519PrivateKeyParameters(privateKey, 0) | ||||
|         val signer = Ed25519Signer() | ||||
|         signer.init(true, privateKeyParams) | ||||
|  | ||||
|         signer.update(data, 0, data.size) | ||||
|         return signer.generateSignature() | ||||
|     } | ||||
| } | ||||
							
								
								
									
										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 | ||||
|  | ||||
| import android.content.Context | ||||
| import com.acitelight.aether.model.BookMark | ||||
| import com.acitelight.aether.model.Comic | ||||
| import com.acitelight.aether.model.ComicResponse | ||||
| import com.acitelight.aether.model.Video | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.withContext | ||||
| import java.io.IOException | ||||
| import com.tonyodev.fetch2.Status | ||||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import kotlinx.serialization.json.Json | ||||
| import java.io.File | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Singleton | ||||
|  | ||||
|  | ||||
| object MediaManager | ||||
| @Singleton | ||||
| class MediaManager @Inject constructor( | ||||
|     val fetchManager: FetchManager, | ||||
|     @ApplicationContext val context: Context | ||||
| ) | ||||
| { | ||||
|     var token: String = "null" | ||||
|  | ||||
| @@ -15,7 +25,7 @@ object MediaManager | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             val j = ApiClient.api.getVideoClasses(token) | ||||
|             val j = ApiClient.api!!.getVideoClasses(token) | ||||
|             return j.toList() | ||||
|         }catch(e: Exception) | ||||
|         { | ||||
| @@ -23,14 +33,13 @@ object MediaManager | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun listVideos(klass: String): List<Video> | ||||
|     suspend fun queryVideoKlasses(klass: String): List<String> | ||||
|     { | ||||
|         try { | ||||
|             val j = ApiClient.api.queryVideoClasses(klass, token) | ||||
|             return j.map{ | ||||
|                 queryVideo(klass, it)!! | ||||
|             }.toList() | ||||
|         }catch (e: Exception) | ||||
|         try | ||||
|         { | ||||
|             val j = ApiClient.api!!.queryVideoClasses(klass, token) | ||||
|             return j.toList() | ||||
|         }catch(e: Exception) | ||||
|         { | ||||
|             return listOf() | ||||
|         } | ||||
| @@ -38,22 +47,130 @@ object MediaManager | ||||
|  | ||||
|     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 { | ||||
|             val j = ApiClient.api.queryVideo(klass, id, token) | ||||
|             return Video(klass = klass, id = id, token=token, j) | ||||
|             val j = ApiClient.api!!.queryVideo(klass, id, token) | ||||
|             return Video(klass = klass, id = id, token=token, isLocal = false, localBase = "", video = j) | ||||
|         }catch (e: Exception) | ||||
|         { | ||||
|             return null | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun listComics() : List<String> | ||||
|     { | ||||
|         return ApiClient.api.getComicCollections() | ||||
|     suspend fun queryVideoBulk(klass: String, id: List<String>): List<Video>? { | ||||
|         return try { | ||||
|             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 | ||||
|  | ||||
| import android.content.Context | ||||
| import androidx.compose.runtime.mutableStateListOf | ||||
| import com.acitelight.aether.model.Comic | ||||
| import com.acitelight.aether.model.Video | ||||
| import com.acitelight.aether.model.VideoQueryIndex | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import kotlinx.coroutines.sync.Mutex | ||||
| import kotlinx.coroutines.sync.withLock | ||||
| import kotlinx.coroutines.withContext | ||||
| @@ -13,12 +13,17 @@ import kotlinx.serialization.json.* | ||||
| import java.io.File | ||||
| import java.io.FileNotFoundException | ||||
| import java.io.IOException | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Singleton | ||||
|  | ||||
| object RecentManager | ||||
| @Singleton | ||||
| class RecentManager @Inject constructor( | ||||
|     private val mediaManager: MediaManager | ||||
| ) | ||||
| { | ||||
|     private val mutex = Mutex() | ||||
|  | ||||
|     suspend fun readFile(context: Context, filename: String): String { | ||||
|     private suspend fun readFile(context: Context, filename: String): String { | ||||
|         return withContext(Dispatchers.IO) { | ||||
|             try { | ||||
|                 val file = File(context.filesDir, filename) | ||||
| @@ -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) { | ||||
|             try { | ||||
|                 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") | ||||
|         try{ | ||||
|             val r = Json.decodeFromString<List<VideoQueryIndex>>(content) | ||||
|  | ||||
|             val vn = r.map{ | ||||
|                 MediaManager.queryVideo(it.klass, it.id) | ||||
|             }.filter { it != null } | ||||
|             recentVideo.clear() | ||||
|             val gr = r.groupBy { it.klass } | ||||
|  | ||||
|             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 | ||||
|         }catch (e: Exception) | ||||
|         { | ||||
| @@ -64,36 +149,29 @@ object RecentManager | ||||
|         return listOf() | ||||
|     } | ||||
|  | ||||
|     suspend fun Push(context: Context, video: VideoQueryIndex) | ||||
|     suspend fun pushVideo(context: Context, video: VideoQueryIndex) | ||||
|     { | ||||
|         mutex.withLock{ | ||||
|             val content = readFile(context, "recent.json") | ||||
|             var o = Json.decodeFromString<List<VideoQueryIndex>>(content).toMutableList(); | ||||
|             val o = recentVideo.map{ VideoQueryIndex(it.klass, it.id) }.toMutableList() | ||||
|  | ||||
|             if(o.contains(video)) | ||||
|             { | ||||
|                 val temp = o[0] | ||||
|                 val index = o.indexOf(video) | ||||
|                 o[0] = o[index] | ||||
|                 o[index] = temp | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 o.add(0, video) | ||||
|                 val temp = recentVideo[index] | ||||
|  | ||||
|                 recentVideo.removeAt(index) | ||||
|             } | ||||
|             recentVideo.add(0, mediaManager.queryVideo(video.klass, video.id)!!) | ||||
|  | ||||
|             if(o.size >= 21) | ||||
|                 o.removeAt(o.size - 1) | ||||
|  | ||||
|             val vn = o.map{ | ||||
|                 MediaManager.queryVideo(it.klass, it.id) | ||||
|             }.filter { it != null } | ||||
|             _recent.value = vn.map { it!! } | ||||
|             if(recentVideo.size >= 21) | ||||
|                 recentVideo.removeAt(o.size - 1) | ||||
|  | ||||
|             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 recent: StateFlow<List<Video>> = _recent | ||||
|     val recentVideo = mutableStateListOf<Video>() | ||||
|     val recentComic = mutableStateListOf<Comic>() | ||||
| } | ||||
| @@ -0,0 +1,85 @@ | ||||
| package com.acitelight.aether.service | ||||
|  | ||||
| import android.content.Context | ||||
| import androidx.datastore.core.DataStore | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| import androidx.datastore.preferences.core.booleanPreferencesKey | ||||
| import androidx.datastore.preferences.core.edit | ||||
| import androidx.datastore.preferences.core.stringPreferencesKey | ||||
| import androidx.datastore.preferences.preferencesDataStore | ||||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.flow.map | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Singleton | ||||
|  | ||||
| private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") | ||||
|  | ||||
| @Singleton | ||||
| class SettingsDataStoreManager @Inject constructor( | ||||
|     @ApplicationContext private val context: Context | ||||
| ) { | ||||
|     companion object { | ||||
|         val USER_NAME_KEY = stringPreferencesKey("user_name") | ||||
|         val PRIVATE_KEY = stringPreferencesKey("private_key") | ||||
|         val URL_KEY = stringPreferencesKey("url") | ||||
|         val CERT_KEY = stringPreferencesKey("cert") | ||||
|         val USE_SELF_SIGNED_KEY = booleanPreferencesKey("use_self_signed") | ||||
|     } | ||||
|  | ||||
|     val userNameFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||
|         preferences[USER_NAME_KEY] ?: "" | ||||
|     } | ||||
|  | ||||
|     val privateKeyFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||
|         preferences[PRIVATE_KEY] ?: "" | ||||
|     } | ||||
|  | ||||
|     val urlFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||
|         preferences[URL_KEY] ?: "" | ||||
|     } | ||||
|  | ||||
|     val certFlow: Flow<String> = context.dataStore.data.map { preferences -> | ||||
|         preferences[CERT_KEY] ?: "" | ||||
|     } | ||||
|  | ||||
|     val useSelfSignedFlow: Flow<Boolean> = context.dataStore.data.map { preferences -> | ||||
|         preferences[USE_SELF_SIGNED_KEY] ?: false | ||||
|     } | ||||
|  | ||||
|     suspend fun saveUserName(name: String) { | ||||
|         context.dataStore.edit { preferences -> | ||||
|             preferences[USER_NAME_KEY] = name | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun savePrivateKey(key: String) { | ||||
|         context.dataStore.edit { preferences -> | ||||
|             preferences[PRIVATE_KEY] = key | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun saveUrl(url: String) { | ||||
|         context.dataStore.edit { preferences -> | ||||
|             preferences[URL_KEY] = url | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun saveCert(cert: String) { | ||||
|         context.dataStore.edit { preferences -> | ||||
|             preferences[CERT_KEY] = cert | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun saveUseSelfSigned(useSelfSigned: Boolean) { | ||||
|         context.dataStore.edit { preferences -> | ||||
|             preferences[USE_SELF_SIGNED_KEY] = useSelfSigned | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun clearAll() { | ||||
|         context.dataStore.edit { preferences -> | ||||
|             preferences.clear() | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,20 @@ | ||||
| package com.acitelight.aether.service | ||||
|  | ||||
| import android.content.Context | ||||
| import androidx.compose.runtime.mutableStateListOf | ||||
| import androidx.compose.runtime.mutableStateMapOf | ||||
| import androidx.compose.runtime.snapshots.SnapshotStateList | ||||
| import com.acitelight.aether.model.Video | ||||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import javax.inject.Inject | ||||
| import javax.inject.Singleton | ||||
|  | ||||
| @Singleton | ||||
| class VideoLibrary @Inject constructor( | ||||
|     @ApplicationContext private val context: Context | ||||
| )  { | ||||
|  | ||||
|     var classes = mutableStateListOf<String>() | ||||
|     val classesMap = mutableStateMapOf<String, SnapshotStateList<Video>>() | ||||
|     val updatingMap: MutableMap<Int, Boolean> = mutableMapOf() | ||||
| } | ||||
| @@ -3,35 +3,135 @@ package com.acitelight.aether.ui.theme | ||||
| import android.app.Activity | ||||
| import android.os.Build | ||||
| import androidx.compose.foundation.isSystemInDarkTheme | ||||
| import androidx.compose.material3.ColorScheme | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.darkColorScheme | ||||
| import androidx.compose.material3.dynamicDarkColorScheme | ||||
| import androidx.compose.material3.dynamicLightColorScheme | ||||
| import androidx.compose.material3.lightColorScheme | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.graphics.lerp | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
|  | ||||
| private val DarkColorScheme = darkColorScheme( | ||||
|     primary = Purple80, | ||||
|     secondary = PurpleGrey80, | ||||
|     tertiary = Pink80 | ||||
| ) | ||||
| fun generateColorScheme(primaryColor: Color, isDarkMode: Boolean): ColorScheme { | ||||
|  | ||||
| private val LightColorScheme = lightColorScheme( | ||||
|     primary = Purple40, | ||||
|     secondary = PurpleGrey40, | ||||
|     tertiary = Pink40 | ||||
|     val background = if (isDarkMode) Color(0xFF121212) else Color(0xFFFFFFFF) | ||||
|     val surface = if (isDarkMode) Color(0xFF1E1E1E) else Color(0xFFFFFFFF) | ||||
|  | ||||
|     /* Other default colors to override | ||||
|     background = Color(0xFFFFFBFE), | ||||
|     surface = Color(0xFFFFFBFE), | ||||
|     onPrimary = Color.White, | ||||
|     onSecondary = Color.White, | ||||
|     onTertiary = Color.White, | ||||
|     onBackground = Color(0xFF1C1B1F), | ||||
|     onSurface = Color(0xFF1C1B1F), | ||||
|     */ | ||||
| ) | ||||
|     val surfaceContainer = if (isDarkMode) Color(0xFF232323) else Color(0xFFFDFDFD) | ||||
|     val surfaceContainerLow = if (isDarkMode) Color(0xFF1A1A1A) else Color(0xFFF5F5F5) | ||||
|     val surfaceContainerHigh = if (isDarkMode) Color(0xFF2A2A2A) else Color(0xFFFAFAFA) | ||||
|     val surfaceContainerHighest = if (isDarkMode) Color(0xFF333333) else Color(0xFFFFFFFF) | ||||
|     val surfaceContainerLowest = if (isDarkMode) Color(0xFF0F0F0F) else Color(0xFFF0F0F0) | ||||
|     val surfaceBright = if (isDarkMode) Color(0xFF2C2C2C) else Color(0xFFFFFFFF) | ||||
|     val surfaceDim = if (isDarkMode) Color(0xFF141414) else Color(0xFFF8F8F8) | ||||
|  | ||||
|     fun tint(surface: Color, factor: Float) = lerp(surface, primaryColor, factor) | ||||
|  | ||||
|     return if (isDarkMode) { | ||||
|         darkColorScheme( | ||||
|             primary = primaryColor, | ||||
|             onPrimary = Color.White, | ||||
|             primaryContainer = tint(primaryColor, 0.2f), | ||||
|             onPrimaryContainer = Color.White, | ||||
|             inversePrimary = tint(primaryColor, 0.6f), | ||||
|  | ||||
|             secondary = tint(primaryColor, 0.4f), | ||||
|             onSecondary = Color.White, | ||||
|             secondaryContainer = tint(primaryColor, 0.2f), | ||||
|             onSecondaryContainer = Color.White, | ||||
|  | ||||
|             tertiary = tint(primaryColor, 0.5f), | ||||
|             onTertiary = Color.White, | ||||
|             tertiaryContainer = tint(primaryColor, 0.2f), | ||||
|             onTertiaryContainer = Color.White, | ||||
|  | ||||
|             background = background, | ||||
|             onBackground = Color.White, | ||||
|  | ||||
|             surface = surface, | ||||
|             onSurface = Color.White, | ||||
|  | ||||
|             surfaceVariant = tint(surface, 0.1f), | ||||
|             onSurfaceVariant = Color(0xFFE0E0E0), | ||||
|  | ||||
|             surfaceTint = primaryColor, | ||||
|  | ||||
|             inverseSurface = Color.White, | ||||
|             inverseOnSurface = Color(0xFF121212), | ||||
|  | ||||
|             error = Color(0xFFCF6679), | ||||
|             onError = Color.Black, | ||||
|             errorContainer = Color(0xFFB00020), | ||||
|             onErrorContainer = Color.White, | ||||
|  | ||||
|             outline = Color(0xFF757575), | ||||
|             outlineVariant = Color(0xFF494949), | ||||
|             scrim = Color.Black, | ||||
|  | ||||
|             surfaceBright = tint(surfaceBright, 0.1f), | ||||
|             surfaceContainer = tint(surfaceContainer, 0.1f), | ||||
|             surfaceContainerHigh = tint(surfaceContainerHigh, 0.12f), | ||||
|             surfaceContainerHighest = tint(surfaceContainerHighest, 0.15f), | ||||
|             surfaceContainerLow = tint(surfaceContainerLow, 0.08f), | ||||
|             surfaceContainerLowest = tint(surfaceContainerLowest, 0.05f), | ||||
|             surfaceDim = tint(surfaceDim, 0.1f) | ||||
|         ) | ||||
|     } else { | ||||
|         lightColorScheme( | ||||
|             primary = primaryColor, | ||||
|             onPrimary = Color.White, | ||||
|             primaryContainer = tint(primaryColor, 0.3f), | ||||
|             onPrimaryContainer = Color.Black, | ||||
|             inversePrimary = tint(primaryColor, 0.6f), | ||||
|  | ||||
|             secondary = tint(primaryColor, 0.4f), | ||||
|             onSecondary = Color.Black, | ||||
|             secondaryContainer = tint(primaryColor, 0.2f), | ||||
|             onSecondaryContainer = Color.Black, | ||||
|  | ||||
|             tertiary = tint(primaryColor, 0.5f), | ||||
|             onTertiary = Color.Black, | ||||
|             tertiaryContainer = tint(primaryColor, 0.3f), | ||||
|             onTertiaryContainer = Color.Black, | ||||
|  | ||||
|             background = background, | ||||
|             onBackground = Color.Black, | ||||
|  | ||||
|             surface = surface, | ||||
|             onSurface = Color.Black, | ||||
|  | ||||
|             surfaceVariant = tint(surface, 0.1f), | ||||
|             onSurfaceVariant = Color(0xFF49454F), | ||||
|  | ||||
|             surfaceTint = primaryColor, | ||||
|  | ||||
|             inverseSurface = Color(0xFF121212), | ||||
|             inverseOnSurface = Color.White, | ||||
|  | ||||
|             error = Color(0xFFB00020), | ||||
|             onError = Color.White, | ||||
|             errorContainer = Color(0xFFFFDAD6), | ||||
|             onErrorContainer = Color.Black, | ||||
|  | ||||
|             outline = Color(0xFF737373), | ||||
|             outlineVariant = Color(0xFFD0C4C9), | ||||
|             scrim = Color.Black, | ||||
|  | ||||
|             surfaceBright = tint(surfaceBright, 0.1f), | ||||
|             surfaceContainer = tint(surfaceContainer, 0.1f), | ||||
|             surfaceContainerHigh = tint(surfaceContainerHigh, 0.12f), | ||||
|             surfaceContainerHighest = tint(surfaceContainerHighest, 0.15f), | ||||
|             surfaceContainerLow = tint(surfaceContainerLow, 0.08f), | ||||
|             surfaceContainerLowest = tint(surfaceContainerLowest, 0.05f), | ||||
|             surfaceDim = tint(surfaceDim, 0.05f) | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| private val DarkColorScheme = generateColorScheme(Color(0xFF36A0FF), isDarkMode = true) | ||||
| private val LightColorScheme = generateColorScheme(Color(0xFF36A0FF), isDarkMode = false) | ||||
|  | ||||
| @Composable | ||||
| fun AetherTheme( | ||||
| @@ -41,11 +141,6 @@ fun AetherTheme( | ||||
|     content: @Composable () -> Unit | ||||
| ) { | ||||
|     val colorScheme = when { | ||||
|         dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { | ||||
|             val context = LocalContext.current | ||||
|             if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) | ||||
|         } | ||||
|  | ||||
|         darkTheme -> DarkColorScheme | ||||
|         else -> LightColorScheme | ||||
|     } | ||||
|   | ||||
							
								
								
									
										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 | ||||
|  | ||||
| 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.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.layout.ContentScale | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.unit.sp | ||||
| import androidx.lifecycle.viewmodel.compose.viewModel | ||||
| import coil3.compose.AsyncImage | ||||
| import com.acitelight.aether.model.Video | ||||
| import com.acitelight.aether.viewModel.VideoScreenViewModel | ||||
| import androidx.compose.material3.ScrollableTabRow | ||||
| import androidx.compose.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 java.nio.charset.Charset | ||||
|  | ||||
| @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 | ||||
|  | ||||
| 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.Spacer | ||||
| import androidx.compose.foundation.layout.PaddingValues | ||||
| import androidx.compose.foundation.layout.fillMaxHeight | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.heightIn | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.layout.wrapContentHeight | ||||
| import androidx.compose.foundation.lazy.LazyColumn | ||||
| import androidx.compose.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.HorizontalDivider | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.draw.alpha | ||||
| import androidx.compose.ui.tooling.preview.Preview | ||||
| import androidx.compose.ui.graphics.Brush | ||||
| import androidx.compose.ui.graphics.Color | ||||
| import androidx.compose.ui.layout.ContentScale | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.text.font.FontWeight | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.unit.sp | ||||
| import androidx.lifecycle.viewmodel.compose.viewModel | ||||
| import androidx.navigation.NavController | ||||
| import com.acitelight.aether.Global | ||||
| import com.acitelight.aether.service.MediaManager | ||||
| import com.acitelight.aether.service.RecentManager | ||||
| import androidx.navigation.NavHostController | ||||
| import coil3.compose.AsyncImage | ||||
| import coil3.request.ImageRequest | ||||
| import com.acitelight.aether.Global.updateRelate | ||||
| import com.acitelight.aether.model.Comic | ||||
| import com.acitelight.aether.viewModel.ComicScreenViewModel | ||||
| import com.acitelight.aether.viewModel.HomeScreenViewModel | ||||
| import kotlinx.coroutines.launch | ||||
|  | ||||
| @Composable | ||||
| fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel(), navController: NavController) | ||||
| fun HomeScreen( | ||||
|     homeScreenViewModel: HomeScreenViewModel = androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel<HomeScreenViewModel>(), | ||||
|     navController: NavHostController) | ||||
| { | ||||
|     if(Global.loggedIn) | ||||
|         homeScreenViewModel.Init() | ||||
|     val recent by RecentManager.recent.collectAsState() | ||||
|     val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 }) | ||||
|  | ||||
|     LazyColumn(modifier = Modifier.fillMaxWidth()) | ||||
|     { | ||||
|         item() | ||||
|     HorizontalPager( | ||||
|         state = pagerState, | ||||
|         modifier = Modifier.fillMaxSize().background(Color.Black) | ||||
|     ){ | ||||
|         p -> | ||||
|         if(p == 0) | ||||
|         { | ||||
|             Column { | ||||
|             Column(Modifier.fillMaxHeight()) { | ||||
|                 Text( | ||||
|                     text = "Recent", | ||||
|                     text = "Videos", | ||||
|                     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) | ||||
|  | ||||
|                 for(i in recent) | ||||
|                 LazyColumn(modifier = Modifier.fillMaxWidth()) | ||||
|                 { | ||||
|                     MiniVideoCard( | ||||
|                         modifier = Modifier | ||||
|                             .padding(horizontal = 12.dp), | ||||
|                         i, | ||||
|                         { | ||||
|                             Global.sameClassVideos = recent | ||||
|                             val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }" | ||||
|                             navController.navigate(route) | ||||
|                         }) | ||||
|                     HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color) | ||||
|                     items(homeScreenViewModel.recentManager.recentVideo) | ||||
|                     { | ||||
|                             i -> | ||||
|                         MiniVideoCard( | ||||
|                             modifier = Modifier | ||||
|                                 .padding(horizontal = 12.dp), | ||||
|                             i, | ||||
|                             { | ||||
|                                 updateRelate(homeScreenViewModel.recentManager.recentVideo, i) | ||||
|                                 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 | ||||
|  | ||||
| import android.util.Log | ||||
| import android.widget.Toast | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.layout.width | ||||
| import androidx.compose.foundation.lazy.LazyColumn | ||||
| import androidx.compose.foundation.text.KeyboardOptions | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.Key | ||||
| import androidx.compose.material.icons.filled.Link | ||||
| import androidx.compose.material.icons.filled.Person | ||||
| import androidx.compose.material.icons.filled.Security | ||||
| import androidx.compose.material3.Button | ||||
| import androidx.compose.material3.Card | ||||
| import androidx.compose.material3.Checkbox | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.OutlinedTextField | ||||
| @@ -25,19 +32,30 @@ import androidx.compose.runtime.setValue | ||||
| import androidx.compose.ui.Alignment | ||||
| import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.compose.ui.text.TextStyle | ||||
| import androidx.compose.ui.text.input.KeyboardType | ||||
| import androidx.compose.ui.unit.dp | ||||
| import androidx.compose.ui.unit.sp | ||||
| import androidx.hilt.navigation.compose.hiltViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import androidx.lifecycle.viewmodel.compose.viewModel | ||||
| import com.acitelight.aether.service.ApiClient.api | ||||
| import com.acitelight.aether.viewModel.MeScreenViewModel | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.withContext | ||||
|  | ||||
| @Composable | ||||
| fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) | ||||
| { | ||||
| fun MeScreen(meScreenViewModel: MeScreenViewModel = androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel<MeScreenViewModel>()) { | ||||
|     val context = LocalContext.current | ||||
|     var username by meScreenViewModel.username; | ||||
|     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 | ||||
|             .fillMaxSize() | ||||
|             .padding(8.dp), | ||||
| @@ -45,62 +63,164 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) | ||||
|         verticalArrangement = Arrangement.Top | ||||
|     ) { | ||||
|         // Card component for a clean, contained UI block | ||||
|         Card( | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .padding(8.dp) | ||||
|         ) { | ||||
|             Column( | ||||
|         item{ | ||||
|             Card( | ||||
|                 modifier = Modifier | ||||
|                     .padding(16.dp) | ||||
|                     .fillMaxWidth(), | ||||
|                 horizontalAlignment = Alignment.CenterHorizontally | ||||
|             ) { | ||||
|                 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, context) | ||||
|                     }, | ||||
|                     modifier = Modifier.fillMaxWidth() | ||||
|                     .fillMaxWidth() | ||||
|                     .padding(8.dp) | ||||
|             ) | ||||
|             { | ||||
|                 Column( | ||||
|                     modifier = Modifier | ||||
|                         .padding(16.dp) | ||||
|                         .fillMaxWidth(), | ||||
|                     horizontalAlignment = Alignment.CenterHorizontally | ||||
|                 ) { | ||||
|                     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 | ||||
|  | ||||
| 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.clickable | ||||
| import androidx.compose.foundation.combinedClickable | ||||
| import androidx.compose.foundation.layout.Arrangement | ||||
| import androidx.compose.foundation.layout.Box | ||||
| import androidx.compose.foundation.layout.Column | ||||
| import androidx.compose.foundation.layout.PaddingValues | ||||
| import androidx.compose.foundation.layout.Row | ||||
| import androidx.compose.foundation.layout.Spacer | ||||
| import androidx.compose.foundation.layout.fillMaxHeight | ||||
| import androidx.compose.foundation.layout.fillMaxSize | ||||
| import androidx.compose.foundation.layout.fillMaxWidth | ||||
| import androidx.compose.foundation.layout.height | ||||
| import androidx.compose.foundation.layout.heightIn | ||||
| import androidx.compose.foundation.layout.padding | ||||
| import androidx.compose.foundation.layout.size | ||||
| import androidx.compose.foundation.layout.width | ||||
| import androidx.compose.foundation.layout.widthIn | ||||
| import androidx.compose.foundation.layout.wrapContentHeight | ||||
| import androidx.compose.foundation.lazy.LazyColumn | ||||
| import androidx.compose.foundation.lazy.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.lazy.staggeredgrid.LazyVerticalStaggeredGrid | ||||
| import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells | ||||
| import androidx.compose.foundation.lazy.staggeredgrid.items | ||||
| import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState | ||||
| import androidx.compose.foundation.shape.RoundedCornerShape | ||||
| import androidx.compose.foundation.text.BasicTextField | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.filled.Menu | ||||
| import androidx.compose.material.icons.filled.Search | ||||
| import androidx.compose.material3.Button | ||||
| 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.HorizontalDivider | ||||
| import androidx.compose.material3.Icon | ||||
| import androidx.compose.material3.LocalTextStyle | ||||
| import androidx.compose.material3.MaterialTheme | ||||
| import androidx.compose.material3.PrimaryScrollableTabRow | ||||
| import androidx.compose.material3.Tab | ||||
| import androidx.compose.material3.TabRow | ||||
| import androidx.compose.material3.TabRowDefaults | ||||
| import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset | ||||
| import androidx.compose.material3.Text | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.collectAsState | ||||
| @@ -39,20 +63,35 @@ import androidx.compose.ui.unit.sp | ||||
| import androidx.lifecycle.viewmodel.compose.viewModel | ||||
| import coil3.compose.AsyncImage | ||||
| import com.acitelight.aether.model.Video | ||||
| import com.acitelight.aether.service.MediaManager | ||||
| import com.acitelight.aether.viewModel.VideoScreenViewModel | ||||
| import androidx.compose.material3.PrimaryTabRow | ||||
| import androidx.compose.material3.ScrollableTabRow | ||||
| import androidx.compose.runtime.LaunchedEffect | ||||
| import androidx.compose.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.graphics.Brush | ||||
| 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 coil3.request.ImageRequest | ||||
| import com.acitelight.aether.CardPage | ||||
| 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.security.KeyPair | ||||
|  | ||||
| fun String.toHex(): String { | ||||
|     return this.toByteArray().joinToString("") { "%02x".format(it) } | ||||
| @@ -70,69 +109,246 @@ fun String.hexToString(charset: Charset = Charsets.UTF_8): String { | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navController: NavHostController) | ||||
| { | ||||
|     val videoList by videoScreenViewModel.videos.collectAsState() | ||||
| fun VideoScreen( | ||||
|     videoScreenViewModel: VideoScreenViewModel = hiltViewModel<VideoScreenViewModel>(), | ||||
|     navController: NavHostController | ||||
| ) { | ||||
|     val state = rememberLazyStaggeredGridState() | ||||
|     val colorScheme = MaterialTheme.colorScheme | ||||
|     val tabIndex by videoScreenViewModel.tabIndex | ||||
|     var menuVisibility by videoScreenViewModel.menuVisibility | ||||
|     var searchFilter by videoScreenViewModel.searchFilter | ||||
|     var doneInit by videoScreenViewModel.doneInit | ||||
|  | ||||
|     Column( | ||||
|         modifier = Modifier.fillMaxSize() // 或至少 fillMaxWidth() | ||||
|     ){ | ||||
|         TopRow(videoScreenViewModel); | ||||
|     if (doneInit) | ||||
|         CardPage(title = "Videos") { | ||||
|             Box(Modifier.fillMaxSize()) | ||||
|             { | ||||
|                 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( | ||||
|             columns = GridCells.Fixed(2), | ||||
|             contentPadding = PaddingValues(8.dp), | ||||
|             verticalArrangement = Arrangement.spacedBy(8.dp), | ||||
|             horizontalArrangement = Arrangement.spacedBy(8.dp) | ||||
|         ) | ||||
|         { | ||||
|             items(videoList) { video -> | ||||
|                 VideoCard(video, navController, videoScreenViewModel) | ||||
|                         Card( | ||||
|                             shape = RoundedCornerShape(8.dp), | ||||
|                             colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||
|                             modifier = Modifier | ||||
|                                 .align(Alignment.CenterVertically) | ||||
|                                 .padding(horizontal = 2.dp) | ||||
|                                 .height(36.dp), | ||||
|                             onClick = { | ||||
|                                 menuVisibility = !menuVisibility | ||||
|                             }) | ||||
|                         { | ||||
|                             Box(Modifier.fillMaxHeight()) | ||||
|                             { | ||||
|                                 Text( | ||||
|                                     text = videoScreenViewModel.videoLibrary.classes.getOrNull( | ||||
|                                         tabIndex | ||||
|                                     ) | ||||
|                                         ?: "", | ||||
|                                     style = MaterialTheme.typography.bodyLarge, | ||||
|                                     fontWeight = FontWeight.Bold, | ||||
|                                     modifier = Modifier | ||||
|                                         .align(Alignment.CenterStart) | ||||
|                                         .padding(horizontal = 8.dp), | ||||
|                                     maxLines = 1 | ||||
|                                 ) | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                         Row( | ||||
|                             modifier = Modifier | ||||
|                                 .height(36.dp) | ||||
|                                 .widthIn(max = 240.dp) | ||||
|                                 .background(colorScheme.primary, RoundedCornerShape(8.dp)) | ||||
|                                 .padding(horizontal = 6.dp) | ||||
|                         ) { | ||||
|                             Icon( | ||||
|                                 modifier = Modifier | ||||
|                                     .size(30.dp) | ||||
|                                     .align(Alignment.CenterVertically), | ||||
|                                 imageVector = Icons.Default.Search, | ||||
|                                 contentDescription = "Catalogue" | ||||
|                             ) | ||||
|                             Spacer(Modifier.width(4.dp)) | ||||
|                             BasicTextField( | ||||
|                                 value = searchFilter, | ||||
|                                 onValueChange = { searchFilter = it }, | ||||
|                                 textStyle = LocalTextStyle.current.copy( | ||||
|                                     fontSize = 18.sp, | ||||
|                                     color = Color.White, | ||||
|                                     textAlign = TextAlign.Start | ||||
|                                 ), | ||||
|                                 singleLine = true, | ||||
|                                 modifier = Modifier.align(Alignment.CenterVertically) | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
|                     HorizontalDivider( | ||||
|                         Modifier.padding(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 | ||||
| fun VideoCard(video: Video, navController: NavHostController, videoScreenViewModel: VideoScreenViewModel) { | ||||
|     val videoList by videoScreenViewModel.videos.collectAsState() | ||||
|  | ||||
| fun CatalogueItemRow( | ||||
|     item: Pair<Int, String>, | ||||
|     onItemClick: (Pair<Int, String>) -> Unit | ||||
| ) { | ||||
|     val colorScheme = MaterialTheme.colorScheme | ||||
|     Card( | ||||
|         modifier = Modifier | ||||
|             .clickable { onItemClick(item) } | ||||
|             .padding(4.dp) | ||||
|             .padding(horizontal = 4.dp) | ||||
|             .heightIn(min = 28.dp) | ||||
|             .width(250.dp), | ||||
|         shape = RoundedCornerShape(8.dp), | ||||
|         colors = CardDefaults.cardColors(containerColor = colorScheme.primary) | ||||
|     ) { | ||||
|         Text( | ||||
|             text = item.second, | ||||
|             fontSize = 14.sp, | ||||
|             maxLines = 1, | ||||
|             textAlign = TextAlign.Center, | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|                 .padding(horizontal = 8.dp, vertical = 4.dp) | ||||
|         ) | ||||
|     } | ||||
| } | ||||
|  | ||||
| @Composable | ||||
| fun VideoCard( | ||||
|     video: Video, | ||||
|     navController: NavHostController, | ||||
|     videoScreenViewModel: VideoScreenViewModel | ||||
| ) { | ||||
|     val tabIndex by videoScreenViewModel.tabIndex; | ||||
|     Card( | ||||
|         shape = RoundedCornerShape(6.dp), | ||||
|         modifier = Modifier | ||||
|             .fillMaxWidth() | ||||
|             .wrapContentHeight(), | ||||
|         onClick = { | ||||
|             Global.sameClassVideos = videoList | ||||
|             val route = "video_player_route/${ "${video.klass}/${video.id}".toHex() }" | ||||
|             navController.navigate(route) | ||||
|         } | ||||
|             .wrapContentHeight() | ||||
|             .combinedClickable( | ||||
|                 onClick = { | ||||
|                     updateRelate( | ||||
|                         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( | ||||
|             modifier = Modifier | ||||
|                 .fillMaxWidth() | ||||
|         )  { | ||||
|             Box(modifier = Modifier.fillMaxSize()){ | ||||
|                 .fillMaxWidth(), | ||||
|         ) { | ||||
|             Box(modifier = Modifier.fillMaxSize()) { | ||||
|  | ||||
|                 AsyncImage( | ||||
|                     model = ImageRequest.Builder(LocalContext.current) | ||||
|                         .data(video.getCover()) | ||||
| @@ -142,39 +358,78 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod | ||||
|                     contentDescription = null, | ||||
|                     modifier = Modifier | ||||
|                         .fillMaxSize(), | ||||
|                     contentScale = ContentScale.Crop | ||||
|                     contentScale = ContentScale.Fit, | ||||
|                     imageLoader = videoScreenViewModel.imageLoader!! | ||||
|                 ) | ||||
|  | ||||
|                 Text( | ||||
|                     modifier = Modifier.align(Alignment.BottomEnd).padding(2.dp), | ||||
|                     text = formatTime(video.video.duration), fontSize = 12.sp, color = Color.White, fontWeight = FontWeight.Bold) | ||||
|                     modifier = Modifier | ||||
|                         .align(Alignment.BottomEnd) | ||||
|                         .padding(2.dp), | ||||
|                     text = formatTime(video.video.duration), | ||||
|                     fontSize = 12.sp, | ||||
|                     fontWeight = FontWeight.Bold | ||||
|                 ) | ||||
|  | ||||
|                 Box( | ||||
|                     Modifier | ||||
|                         .fillMaxWidth() | ||||
|                         .height(24.dp) | ||||
|                         .background( brush = Brush.verticalGradient( | ||||
|                             colors = listOf( | ||||
|                                 Color.Transparent, | ||||
|                                 Color.Black.copy(alpha = 0.45f) | ||||
|                         .background( | ||||
|                             brush = Brush.verticalGradient( | ||||
|                                 colors = listOf( | ||||
|                                     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 = video.video.name, | ||||
|                 fontSize = 14.sp, | ||||
|                 fontSize = 12.sp, | ||||
|                 fontWeight = FontWeight.Bold, | ||||
|                 maxLines = 2, | ||||
|                 modifier = Modifier.padding(8.dp).background(Color.Transparent).heightIn(48.dp) | ||||
|                 maxLines = 4, | ||||
|                 modifier = Modifier | ||||
|                     .padding(8.dp) | ||||
|                     .background(Color.Transparent) | ||||
|                     .heightIn(min = 24.dp), | ||||
|                 lineHeight = 14.sp | ||||
|             ) | ||||
|             Spacer(modifier = Modifier.weight(1f)) | ||||
|             Row( | ||||
|                 modifier = Modifier.padding(horizontal = 8.dp), | ||||
|                 horizontalArrangement = Arrangement.SpaceBetween, | ||||
|             ) { | ||||
|                 Text("Class", fontSize = 12.sp) | ||||
|                 Text("${video.klass}", fontSize = 12.sp) | ||||
|                 Text("Class: ", fontSize = 10.sp, maxLines = 1) | ||||
|                 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 | ||||
|  | ||||
| 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.viewModelScope | ||||
| import coil3.ImageLoader | ||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||
| import com.acitelight.aether.model.Comic | ||||
| import com.acitelight.aether.model.Video | ||||
| import com.acitelight.aether.model.ComicResponse | ||||
| 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.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import kotlinx.coroutines.launch | ||||
| import javax.inject.Inject | ||||
|  | ||||
| class ComicScreenViewModel : ViewModel() | ||||
| { | ||||
|     private val _comics = MutableStateFlow<List<Comic>>(emptyList()) | ||||
|     val comics: StateFlow<List<Comic>> = _comics | ||||
| @HiltViewModel | ||||
| class ComicScreenViewModel @Inject constructor( | ||||
|     @ApplicationContext private val context: Context, | ||||
|     val mediaManager: MediaManager | ||||
| ) : ViewModel() { | ||||
|  | ||||
|     init | ||||
|     { | ||||
|       //  viewModelScope.launch { | ||||
|       //      val l = MediaManager.listComics() | ||||
|        //     _comics.value = l.map { MediaManager.queryComicInfo(it) } | ||||
|        // } | ||||
|     var imageLoader: ImageLoader? = null; | ||||
|  | ||||
|     val comics = mutableStateListOf<Comic>() | ||||
|     val excluded = mutableStateListOf<String>() | ||||
|     val included = mutableStateListOf<String>() | ||||
|     val tags = mutableStateListOf<String>() | ||||
|     private val counter = mutableMapOf<String, Int>() | ||||
|  | ||||
|     fun insertItem(newItem: String) { | ||||
|         val newCount = (counter[newItem] ?: 0) + 1 | ||||
|         counter[newItem] = newCount | ||||
|  | ||||
|         if (newItem !in tags) { | ||||
|             val insertIndex = tags.indexOfFirst { counter[it]!! < newCount } | ||||
|                 .takeIf { it >= 0 } ?: tags.size | ||||
|             tags.add(insertIndex, newItem) | ||||
|         } else { | ||||
|             var currentIndex = tags.indexOf(newItem) | ||||
|             while (currentIndex > 0 && counter[tags[currentIndex - 1]]!! < newCount) { | ||||
|                 tags[currentIndex] = tags[currentIndex - 1] | ||||
|                 tags[currentIndex - 1] = newItem | ||||
|                 currentIndex-- | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     init { | ||||
|         imageLoader =  ImageLoader.Builder(context) | ||||
|             .components { | ||||
|                 add(OkHttpNetworkFetcherFactory(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 | ||||
|  | ||||
| import android.app.Application | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import android.content.Context | ||||
| import androidx.lifecycle.ViewModel | ||||
| import androidx.compose.runtime.State | ||||
| import androidx.compose.runtime.mutableIntStateOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.datastore.preferences.core.stringPreferencesKey | ||||
| import androidx.lifecycle.AndroidViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import com.acitelight.aether.Global | ||||
| import com.acitelight.aether.dataStore | ||||
| import com.acitelight.aether.model.Video | ||||
| import com.acitelight.aether.model.VideoQueryIndex | ||||
| import 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 coil3.ImageLoader | ||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||
| import com.acitelight.aether.service.ApiClient.createOkHttp | ||||
| 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 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 | ||||
|     private val USER_NAME_KEY = stringPreferencesKey("user_name") | ||||
|     private val PRIVATE_KEY   = stringPreferencesKey("private_key") | ||||
|     var imageLoader: ImageLoader? = null | ||||
|  | ||||
|     val userNameFlow: Flow<String> = dataStore.data.map { preferences -> | ||||
|         preferences[USER_NAME_KEY] ?: "" | ||||
|     } | ||||
|  | ||||
|     val privateKeyFlow: Flow<String> = dataStore.data.map {  preferences -> | ||||
|         preferences[PRIVATE_KEY] ?: "" | ||||
|     } | ||||
|  | ||||
|     var _init = false | ||||
|  | ||||
|     @Composable | ||||
|     fun Init(){ | ||||
|         if(_init) return | ||||
|         _init = true | ||||
|  | ||||
|         val context = LocalContext.current | ||||
|         remember { | ||||
|             viewModelScope.launch { | ||||
|                 RecentManager.Query(context) | ||||
|     init{ | ||||
|         imageLoader =  ImageLoader.Builder(context) | ||||
|             .components { | ||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     init { | ||||
|             .build() | ||||
|         viewModelScope.launch { | ||||
|             val u = userNameFlow.first() | ||||
|             val p = privateKeyFlow.first() | ||||
|  | ||||
|             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) | ||||
|             } | ||||
|             recentManager.queryVideo(context) | ||||
|             recentManager.queryComic(context) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -5,10 +5,13 @@ import android.content.Context | ||||
| import android.widget.Toast | ||||
| import androidx.compose.runtime.mutableStateOf | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.core.net.toUri | ||||
| import androidx.datastore.preferences.core.edit | ||||
| import androidx.datastore.preferences.core.stringPreferencesKey | ||||
| import androidx.lifecycle.AndroidViewModel | ||||
| import androidx.lifecycle.ViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import com.acitelight.aether.AetherApp | ||||
| import com.acitelight.aether.Global | ||||
| import com.acitelight.aether.dataStore | ||||
| import com.acitelight.aether.model.Video | ||||
| @@ -20,54 +23,127 @@ import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.first | ||||
| import kotlinx.coroutines.flow.map | ||||
| 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) { | ||||
|     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 MeScreenViewModel @Inject constructor( | ||||
|     private val settingsDataStoreManager: SettingsDataStoreManager, | ||||
|     @ApplicationContext private val context: Context, | ||||
|     val mediaManager: MediaManager | ||||
| ) : ViewModel() { | ||||
|  | ||||
|     val username = mutableStateOf(""); | ||||
|     val privateKey = mutableStateOf("") | ||||
|     val url = mutableStateOf(""); | ||||
|     val cert = mutableStateOf("") | ||||
|  | ||||
|     val uss = settingsDataStoreManager.useSelfSignedFlow | ||||
|  | ||||
|     init { | ||||
|         viewModelScope.launch { | ||||
|             username.value = userNameFlow.first() | ||||
|             privateKey.value = if (privateKeyFlow.first() == "") "" else "******" | ||||
|             username.value = settingsDataStoreManager.userNameFlow.first() | ||||
|             privateKey.value = if (settingsDataStoreManager.privateKeyFlow.first() == "") "" else "******" | ||||
|             url.value = settingsDataStoreManager.urlFlow.first() | ||||
|             cert.value = settingsDataStoreManager.certFlow.first() | ||||
|  | ||||
|             if(username.value=="" || privateKey.value=="" || url.value=="") return@launch | ||||
|  | ||||
|             try{ | ||||
|                 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 { | ||||
|             dataStore.edit { preferences -> | ||||
|                 preferences[USER_NAME_KEY] = u | ||||
|                 preferences[PRIVATE_KEY] = p | ||||
|             settingsDataStoreManager.saveUseSelfSigned(isChecked) | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     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 = "******" | ||||
|  | ||||
|             Global.loggedIn = false | ||||
|  | ||||
|             val u = userNameFlow.first() | ||||
|             val p = privateKeyFlow.first() | ||||
|             val u = settingsDataStoreManager.userNameFlow.first() | ||||
|             val p = settingsDataStoreManager.privateKeyFlow.first() | ||||
|             val ur = settingsDataStoreManager.urlFlow.first() | ||||
|  | ||||
|             if (u == "" || p == "") return@launch | ||||
|             if (u == "" || p == "" || ur == "") return@launch | ||||
|  | ||||
|             try { | ||||
|                 MediaManager.token = AuthManager.fetchToken( | ||||
|                     ApiClient.base, | ||||
|                 mediaManager.token = AuthManager.fetchToken( | ||||
|                     u, | ||||
|                     p | ||||
|                 )!! | ||||
|  | ||||
|                 Global.loggedIn = true | ||||
|                 withContext(Dispatchers.IO) | ||||
|                 { | ||||
|                     (context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096) | ||||
|                     context.abyssService?.downloader?.init() | ||||
|                 } | ||||
|                 Toast.makeText(context, "Account Updated", Toast.LENGTH_SHORT).show() | ||||
|             } catch (e: Exception) { | ||||
|                 print(e.message) | ||||
|   | ||||
| @@ -0,0 +1,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 | ||||
|  | ||||
| import android.content.Context | ||||
| import android.net.Uri | ||||
| import android.widget.Toast | ||||
| import androidx.annotation.OptIn | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.State | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableFloatStateOf | ||||
| import androidx.compose.runtime.mutableIntStateOf | ||||
| @@ -13,80 +15,237 @@ import androidx.compose.ui.platform.LocalContext | ||||
| import androidx.lifecycle.ViewModel | ||||
| import androidx.lifecycle.viewModelScope | ||||
| import androidx.media3.common.MediaItem | ||||
| import androidx.media3.common.PlaybackException | ||||
| import androidx.media3.common.Player | ||||
| import androidx.media3.common.Player.STATE_READY | ||||
| import androidx.media3.common.text.Cue | ||||
| import androidx.media3.common.util.Log | ||||
| import androidx.media3.common.util.UnstableApi | ||||
| import androidx.media3.datasource.okhttp.OkHttpDataSource | ||||
| 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.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.RecentManager | ||||
| import com.acitelight.aether.view.formatTime | ||||
| 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.Dispatchers | ||||
| import kotlinx.coroutines.delay | ||||
| 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 isPlaying by  mutableStateOf(true) | ||||
|     var isPlaying by mutableStateOf(true) | ||||
|     var playProcess by mutableFloatStateOf(0.0f) | ||||
|     var planeVisibility by mutableStateOf(true) | ||||
|     var isLongPressing by mutableStateOf(false) | ||||
|     var dragging by mutableStateOf(false) | ||||
|  | ||||
|     // -1 : Not dragging | ||||
|     // 0  : Seek | ||||
|     // 1  : Volume | ||||
|     // 2  : Brightness | ||||
|     var draggingPurpose by mutableIntStateOf(-1) | ||||
|  | ||||
|     var thumbUp by mutableIntStateOf(0) | ||||
|     var thumbDown by mutableIntStateOf(0) | ||||
|     var star by mutableStateOf(false) | ||||
|  | ||||
|     var locked by mutableStateOf(false) | ||||
|     private var _init: Boolean = false; | ||||
|     var startPlaying by mutableStateOf(false) | ||||
|     var renderedFirst = false | ||||
|     var video: Video? = null | ||||
|  | ||||
|     @Composable | ||||
|     fun Init(videoId: String) | ||||
|     { | ||||
|         if(_init) return; | ||||
|         val context = LocalContext.current | ||||
|     val dataSourceFactory = OkHttpDataSource.Factory(createOkHttp()) | ||||
|     var imageLoader: ImageLoader? = null; | ||||
|     var brit by mutableFloatStateOf(0.5f) | ||||
|     val database: VideoRecordDatabase = VideoRecordDatabase.getDatabase(context) | ||||
|     var cues by mutableStateOf(listOf<Cue>()) | ||||
|  | ||||
|     @OptIn(UnstableApi::class) | ||||
|     fun init(videoId: String) { | ||||
|         if (_init) return; | ||||
|         val v = videoId.hexToString() | ||||
|         imageLoader = ImageLoader.Builder(context) | ||||
|             .components { | ||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) | ||||
|             } | ||||
|             .build() | ||||
|  | ||||
|         remember { | ||||
|             viewModelScope.launch { | ||||
|                 video = MediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!! | ||||
|                 RecentManager.Push(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1])) | ||||
|                 _player = ExoPlayer.Builder(context).build().apply { | ||||
|                     val url = video?.getVideo() ?: "" | ||||
|                     val mediaItem = MediaItem.fromUri(url) | ||||
|                     setMediaItem(mediaItem) | ||||
|                     prepare() | ||||
|                     playWhenReady = true | ||||
|         viewModelScope.launch { | ||||
|             video = mediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!! | ||||
|             recentManager.pushVideo(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1])) | ||||
|  | ||||
|                     addListener(object : Player.Listener { | ||||
|                         override fun onPlaybackStateChanged(playbackState: Int) { | ||||
|                             if (playbackState == STATE_READY) { | ||||
|                                 startPlaying = true | ||||
|             val subtitleCandidate = video?.getSubtitle()?.trim() | ||||
|             val subtitleUri = tryResolveSubtitleUri(subtitleCandidate) | ||||
|  | ||||
|             // 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() { | ||||
|                             super.onRenderedFirstFrame() | ||||
|                             renderedFirst = true | ||||
|                         } | ||||
|                     }) | ||||
|                 } | ||||
|                 startListen() | ||||
|                     override fun onPlayerError(error: PlaybackException) | ||||
|                     { | ||||
|                         print(error.message) | ||||
|                     } | ||||
|  | ||||
|                     override fun onCues(lcues: MutableList<Cue>) { | ||||
|                         cues = lcues | ||||
|                     } | ||||
|                 }) | ||||
|             } | ||||
|             startListen() | ||||
|         } | ||||
|  | ||||
|         _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) | ||||
|     fun startListen() | ||||
|     { | ||||
|     fun startListen() { | ||||
|         CoroutineScope(Dispatchers.Main).launch { | ||||
|             while (_player?.isReleased != true) { | ||||
|                 val __player = _player!!; | ||||
| @@ -100,6 +259,10 @@ class VideoPlayerViewModel() : ViewModel() | ||||
|  | ||||
|     override fun onCleared() { | ||||
|         super.onCleared() | ||||
|         val p = _player!!.currentPosition | ||||
|         _player?.release() | ||||
|         CoroutineScope(Dispatchers.IO).launch { | ||||
|             database.userDao().insert(VideoRecord(video!!.id, video!!.klass, p)) | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1,67 +1,128 @@ | ||||
| package com.acitelight.aether.viewModel | ||||
|  | ||||
| import android.app.Application | ||||
| import android.content.Context | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.runtime.State | ||||
| import androidx.compose.runtime.collectAsState | ||||
| import androidx.compose.runtime.getValue | ||||
| import androidx.compose.runtime.mutableIntStateOf | ||||
| import androidx.datastore.preferences.core.stringPreferencesKey | ||||
| import androidx.lifecycle.AndroidViewModel | ||||
| import androidx.compose.runtime.mutableStateListOf | ||||
| import androidx.compose.runtime.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.viewModelScope | ||||
| import coil3.ImageLoader | ||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||
| import com.acitelight.aether.Global | ||||
| import com.acitelight.aether.dataStore | ||||
| import com.acitelight.aether.model.Video | ||||
| import com.acitelight.aether.service.ApiClient | ||||
| import com.acitelight.aether.service.AuthManager | ||||
| import com.acitelight.aether.service.ApiClient.createOkHttp | ||||
| import com.acitelight.aether.service.FetchManager | ||||
| import com.acitelight.aether.service.MediaManager | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.flow.MutableStateFlow | ||||
| import kotlinx.coroutines.flow.StateFlow | ||||
| import com.acitelight.aether.service.RecentManager | ||||
| import com.acitelight.aether.service.VideoLibrary | ||||
| import com.tonyodev.fetch2.Status | ||||
| import dagger.hilt.android.lifecycle.HiltViewModel | ||||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.flow.filter | ||||
| import kotlinx.coroutines.flow.first | ||||
| import kotlinx.coroutines.flow.map | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.serialization.json.Json | ||||
| import java.io.File | ||||
| import javax.inject.Inject | ||||
| 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) | ||||
|     val tabIndex: State<Int> = _tabIndex | ||||
|  | ||||
|     private val _videos = MutableStateFlow<List<Video>>(emptyList()) | ||||
|     val videos: StateFlow<List<Video>> = _videos | ||||
|     private val _klasses = MutableStateFlow<List<String>>(emptyList()) | ||||
|     val klasses: StateFlow<List<String>> = _klasses; | ||||
|     var imageLoader: ImageLoader? = null; | ||||
|     var menuVisibility = mutableStateOf(false) | ||||
|     var searchFilter = mutableStateOf("") | ||||
|     var doneInit = mutableStateOf(false) | ||||
|  | ||||
|     suspend fun init() { | ||||
|         _klasses.value = MediaManager.listVideoKlasses() | ||||
|         val p = MediaManager.listVideos(_klasses.value.first()) | ||||
|         _videos.value = p | ||||
|         fetchManager.configured.filter { it }.first() | ||||
|  | ||||
|         if (Global.loggedIn) { | ||||
|             videoLibrary.classes.addAll(mediaManager.listVideoKlasses()) | ||||
|             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() | ||||
|         { | ||||
|             _tabIndex.intValue = index; | ||||
|             val p = MediaManager.listVideos(_klasses.value[index]) | ||||
|             _videos.value = p | ||||
|             if (videoLibrary.updatingMap[index] == true) return@launch | ||||
|  | ||||
|             videoLibrary.updatingMap[index] = true | ||||
|  | ||||
|             val vl = mediaManager.queryVideoBulk( | ||||
|                 videoLibrary.classes[index], | ||||
|                 mediaManager.queryVideoKlasses(videoLibrary.classes[index]) | ||||
|             ) | ||||
|  | ||||
|             if (vl != null) { | ||||
|                 val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name }) | ||||
|                 videoLibrary.classesMap[videoLibrary.classes[index]]?.addAll(r) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     suspend fun download(video: Video) { | ||||
|         fetchManager.startVideoDownload(video) | ||||
|     } | ||||
|  | ||||
|     init { | ||||
|         viewModelScope.launch { | ||||
|         imageLoader = ImageLoader.Builder(context) | ||||
|             .components { | ||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) | ||||
|             } | ||||
|             .build() | ||||
|  | ||||
|         viewModelScope.launch(Dispatchers.IO) { | ||||
|             init() | ||||
|         } | ||||
|     } | ||||
|   | ||||
							
								
								
									
										74
									
								
								app/src/main/res/drawable/aether_background.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,74 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <vector | ||||
|     android:height="108dp" | ||||
|     android:width="108dp" | ||||
|     android:viewportHeight="108" | ||||
|     android:viewportWidth="108" | ||||
|     xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <path android:fillColor="#3DDC84" | ||||
|           android:pathData="M0,0h108v108h-108z"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M9,0L9,108" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M19,0L19,108" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M29,0L29,108" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M39,0L39,108" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M49,0L49,108" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M59,0L59,108" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M69,0L69,108" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M79,0L79,108" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M89,0L89,108" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M99,0L99,108" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M0,9L108,9" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M0,19L108,19" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M0,29L108,29" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M0,39L108,39" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M0,49L108,49" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M0,59L108,59" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M0,69L108,69" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M0,79L108,79" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M0,89L108,89" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M0,99L108,99" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M19,29L89,29" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M19,39L89,39" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M19,49L89,49" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M19,59L89,59" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M19,69L89,69" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M19,79L89,79" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M29,19L29,89" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M39,19L39,89" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M49,19L49,89" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M59,19L59,89" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M69,19L69,89" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
|     <path android:fillColor="#00000000" android:pathData="M79,19L79,89" | ||||
|           android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/> | ||||
| </vector> | ||||
							
								
								
									
										5
									
								
								app/src/main/res/mipmap-anydpi-v26/aether.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <background android:drawable="@drawable/aether_background"/> | ||||
|     <foreground android:drawable="@mipmap/aether_foreground"/> | ||||
| </adaptive-icon> | ||||
							
								
								
									
										5
									
								
								app/src/main/res/mipmap-anydpi-v26/aether_round.xml
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,5 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <background android:drawable="@drawable/aether_background"/> | ||||
|     <foreground android:drawable="@mipmap/aether_foreground"/> | ||||
| </adaptive-icon> | ||||
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-hdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-hdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 5.3 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-hdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 4.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-mdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-mdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-mdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xhdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xhdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xhdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.0 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxhdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 6.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxhdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 29 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxhdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 9.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxxhdpi/aether.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 10 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxxhdpi/aether_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 56 KiB | 
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/mipmap-xxxhdpi/aether_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 17 KiB | 
| @@ -3,4 +3,7 @@ plugins { | ||||
|     alias(libs.plugins.android.application) apply false | ||||
|     alias(libs.plugins.kotlin.android) apply false | ||||
|     alias(libs.plugins.kotlin.compose) apply false | ||||
|  | ||||
|     alias(libs.plugins.hilt.android) apply false | ||||
|     alias(libs.plugins.ksp) apply false | ||||
| } | ||||
| @@ -1,5 +1,6 @@ | ||||
| [versions] | ||||
| agp = "8.12.1" | ||||
| agp = "8.13.0" | ||||
| ariaCompiler = "latest" | ||||
| bcprovJdk15on = "1.70" | ||||
| bcprovJdk18on = "1.81" | ||||
| coilCompose = "3.3.0" | ||||
| @@ -7,27 +8,35 @@ coilNetworkOkhttp = "3.3.0" | ||||
| converterGson = "3.0.0" | ||||
| datastorePreferences = "1.1.7" | ||||
| exoplayerplus = "0.2.0" | ||||
| fetch2 = "3.4.1" | ||||
| fetch2okhttp = "3.4.1" | ||||
| gson = "2.13.1" | ||||
| kotlin = "2.2.10" | ||||
| kotlin = "2.2.20" | ||||
| coreKtx = "1.17.0" | ||||
| junit = "4.13.2" | ||||
| junitVersion = "1.3.0" | ||||
| espressoCore = "3.7.0" | ||||
| kotlinxSerializationJson = "1.9.0" | ||||
| kotlinxSerializationJsonVersion = "1.9.0" | ||||
| lifecycleRuntimeKtx = "2.9.2" | ||||
| activityCompose = "1.10.1" | ||||
| composeBom = "2025.08.00" | ||||
| lifecycleRuntimeKtx = "2.9.3" | ||||
| activityCompose = "1.11.0" | ||||
| composeBom = "2025.09.00" | ||||
| media3Common = "1.8.0" | ||||
| media3Exoplayer = "1.8.0" | ||||
| media3ExoplayerFfmpeg = "1.8.0" | ||||
| media3Ui = "1.8.0" | ||||
| navigationCompose = "2.9.3" | ||||
| navigationCompose = "2.9.4" | ||||
| okhttp = "5.1.0" | ||||
| retrofit = "3.0.0" | ||||
| retrofit2KotlinxSerializationConverter = "1.0.0" | ||||
| retrofitVersion = "3.0.0" | ||||
| tinkAndroid = "1.18.0" | ||||
| tweetnaclJava = "1.0.0" | ||||
| media3DatasourceOkhttp = "1.8.0" | ||||
| roomCompiler = "2.8.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] | ||||
| 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-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" } | ||||
| androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } | ||||
| androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" } | ||||
| androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" } | ||||
| androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } | ||||
| aria-compiler = { module = "com.arialyy.aria:aria-compiler", version.ref = "ariaCompiler" } | ||||
| bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bcprovJdk15on" } | ||||
| bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprovJdk18on" } | ||||
| coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" } | ||||
| coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" } | ||||
| converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" } | ||||
| fetch2 = { module = "com.github.tonyofrancis.Fetch:fetch2", version.ref = "fetch2" } | ||||
| fetch2okhttp = { module = "com.github.tonyofrancis.Fetch:fetch2okhttp", version.ref = "fetch2okhttp" } | ||||
| gson = { module = "com.google.code.gson:gson", version.ref = "gson" } | ||||
| junit = { group = "junit", name = "junit", version.ref = "junit" } | ||||
| androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } | ||||
| @@ -57,16 +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-material3 = { group = "androidx.compose.material3", name = "material3" } | ||||
| kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } | ||||
| kotlinx-serialization-json-v163 = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJsonVersion" } | ||||
| okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } | ||||
| retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } | ||||
| retrofit-v2110 = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" } | ||||
| retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" } | ||||
| tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tinkAndroid" } | ||||
| tweetnacl-java = { module = "com.github.InstantWebP2P:tweetnacl-java", version.ref = "tweetnaclJava" } | ||||
| androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" } | ||||
|  | ||||
| hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } | ||||
| hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } | ||||
| hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" } | ||||
| androidx-compose-material-core = { group = "androidx.wear.compose", name = "compose-material-core", version.ref = "composeMaterialCore" } | ||||
|  | ||||
| [plugins] | ||||
| android-application = { id = "com.android.application", version.ref = "agp" } | ||||
| kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } | ||||
| kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } | ||||
|  | ||||
| ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } | ||||
| hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } | ||||
| @@ -16,6 +16,9 @@ dependencyResolutionManagement { | ||||
|     repositories { | ||||
|         google() | ||||
|         mavenCentral() | ||||
|         maven { | ||||
|             url = uri("https://jitpack.io") | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
|   | ||||