Compare commits
	
		
			34 Commits
		
	
	
		
			756c2ea9f8
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 9efbcdfe8a | ||
|   | c3e0a23ed1 | ||
|   | 7be18dd517 | ||
|   | a13ddbdd87 | ||
|   | 390094b8b0 | ||
|   | b360724dca | ||
|   | db8d5ef4d5 | ||
|   | 2c4d5d2366 | ||
|   | 200cf33e5a | ||
|   | 603c2c38aa | ||
|   | 7c99ea394b | ||
|   | 614a0d591d | ||
|   | 24dda0eb2c | ||
|   | c5a5826321 | ||
|   | ba4811e65f | ||
|   | 8b5adfd6b7 | ||
|   | 02d8d30da7 | ||
|   | 422da51a74 | ||
|   | 393419afd7 | ||
|   | 88392444a4 | ||
|   | 2166229923 | ||
|   | 9bad0dcbc2 | ||
|   | c21defb426 | ||
|   | e6b69ef14a | ||
|   | dcef25a526 | ||
|   | cf0c68812d | ||
|   | 22469e1d49 | ||
|   | ba1a7c9a92 | ||
|   | 584fc1f785 | ||
|   | 8184ab211c | ||
|   | 4e346a83ee | ||
|   | 5b770a965d | ||
|   | a89f892306 | ||
|   | e38d77b2f6 | 
							
								
								
									
										25
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								README.md
									
									
									
									
									
								
							| @@ -9,6 +9,31 @@ _🚀This is the client of the multimedia server Abyss, which can also be extend | |||||||
| <img src="aether_clip.png" width="25%" alt="Logo"> | <img src="aether_clip.png" width="25%" alt="Logo"> | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|  | ## 🎯 Target | ||||||
|  |  | ||||||
|  | The ultimate goal of this software project is to enable anyone to easily build a smooth media library that they can fully manage and control,  | ||||||
|  | contribute to with trusted individuals, and securely access from any location without worrying about unauthorized use of their data by third parties.  | ||||||
|  | Undoubtedly, this is a distant goal, but in any case,  | ||||||
|  | I hope this project can make a modest contribution to the advancement of cybersecurity and the protection of user privacy. | ||||||
|  |  | ||||||
|  | ## Key Features | ||||||
|  |  | ||||||
|  | - **Media Management**: Organize and serve images, videos, and live streams with structured directory support. | ||||||
|  | - **User Authentication**: Challenge-response authentication using **Ed25519** signatures. No private keys are ever transmitted. | ||||||
|  | - **Role-Based Access Control (RBAC)**: Hierarchical user privileges with configurable permissions for resources. | ||||||
|  | - **Secure Proxy**: Built-in HTTP/S proxy with end-to-end encrypted tunneling using **X25519** key exchange and **ChaCha20-Poly1305** encryption. | ||||||
|  | - **Resource-Level Permissions**: Fine-grained control over files and directories using a SQLite-based attribute system. | ||||||
|  | - **Task System**: Support for background tasks such as media uploads and processing with chunk-based integrity validation. | ||||||
|  | - **RESTful API**: Fully documented API endpoints for media access, user management, and task control. | ||||||
|  |  | ||||||
|  | ## Technology Stack | ||||||
|  |  | ||||||
|  | - **Backend**: ASP.NET Core 9, MVC, Dependency Injection | ||||||
|  | - **Database**: SQLite with async ORM support | ||||||
|  | - **Cryptography**: NSec.Cryptography (Ed25519, X25519), ChaCha20Poly1305 | ||||||
|  | - **Media Handling**: Range requests, MIME type detection, chunked uploads | ||||||
|  | - **Security**: Rate limiting, IP binding, token expiration, secure headers | ||||||
|  |  | ||||||
| ## Development background | ## Development background | ||||||
|  |  | ||||||
| - Operating System: Voidraw OS v1.1 (based on Ubuntu) or any compatible Linux distribution. | - Operating System: Voidraw OS v1.1 (based on Ubuntu) or any compatible Linux distribution. | ||||||
|   | |||||||
| @@ -46,12 +46,16 @@ android { | |||||||
| } | } | ||||||
|  |  | ||||||
| dependencies { | dependencies { | ||||||
|  |     implementation(libs.persistentcookiejar) | ||||||
|  |  | ||||||
|     implementation(libs.fetch2) |     implementation(libs.fetch2) | ||||||
|     implementation(libs.fetch2okhttp) |     implementation(libs.fetch2okhttp) | ||||||
|  |  | ||||||
|     implementation(libs.hilt.android) |     implementation(libs.hilt.android) | ||||||
|     implementation(libs.hilt.navigation.compose) |     implementation(libs.hilt.navigation.compose) | ||||||
|     implementation(libs.androidx.compose.material.core) |     implementation(libs.androidx.compose.material.core) | ||||||
|  |     implementation(libs.androidx.constraintlayout) | ||||||
|  |     implementation(libs.androidx.compose.animation) | ||||||
|     ksp(libs.hilt.android.compiler) |     ksp(libs.hilt.android.compiler) | ||||||
|  |  | ||||||
|     implementation(libs.androidx.room.runtime) |     implementation(libs.androidx.room.runtime) | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ object Global { | |||||||
|     var sameClassVideos: List<Video>? = null |     var sameClassVideos: List<Video>? = null | ||||||
|     private set |     private set | ||||||
|  |  | ||||||
|  |     var isFullScreen by mutableStateOf(false) | ||||||
|     fun updateRelate(v: List<Video>, s: Video) |     fun updateRelate(v: List<Video>, s: Video) | ||||||
|     { |     { | ||||||
|         sameClassVideos = if (v.contains(s)) { |         sameClassVideos = if (v.contains(s)) { | ||||||
|   | |||||||
| @@ -3,49 +3,41 @@ package com.acitelight.aether | |||||||
| import android.app.Activity | import android.app.Activity | ||||||
| import android.content.Intent | import android.content.Intent | ||||||
| import androidx.compose.material.icons.Icons | import androidx.compose.material.icons.Icons | ||||||
| import android.graphics.drawable.Icon |  | ||||||
| import android.net.http.SslCertificate.saveState |  | ||||||
| import android.os.Bundle | import android.os.Bundle | ||||||
|  | import android.view.View | ||||||
| import android.view.WindowManager | import android.view.WindowManager | ||||||
| import androidx.activity.ComponentActivity | import androidx.activity.ComponentActivity | ||||||
| import androidx.activity.compose.setContent | import androidx.activity.compose.setContent | ||||||
| import androidx.activity.enableEdgeToEdge | import androidx.activity.enableEdgeToEdge | ||||||
|  | import androidx.compose.animation.AnimatedContentTransitionScope | ||||||
| import androidx.compose.animation.AnimatedVisibility | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | import androidx.compose.animation.ExperimentalAnimationApi | ||||||
|  | import androidx.compose.animation.core.tween | ||||||
| import androidx.compose.animation.slideInVertically | import androidx.compose.animation.slideInVertically | ||||||
| import androidx.compose.animation.slideOutVertically | import androidx.compose.animation.slideOutVertically | ||||||
| import androidx.compose.foundation.background | import androidx.compose.foundation.background | ||||||
| import androidx.compose.foundation.isSystemInDarkTheme | import androidx.compose.foundation.isSystemInDarkTheme | ||||||
| import androidx.compose.foundation.layout.Box | import androidx.compose.foundation.layout.Box | ||||||
| import androidx.compose.foundation.layout.Column |  | ||||||
| import androidx.compose.foundation.layout.fillMaxSize | import androidx.compose.foundation.layout.fillMaxSize | ||||||
| import androidx.compose.foundation.layout.height | import androidx.compose.foundation.layout.height | ||||||
| import androidx.compose.foundation.layout.heightIn |  | ||||||
| import androidx.compose.foundation.layout.padding | import androidx.compose.foundation.layout.padding | ||||||
| import androidx.compose.foundation.shape.RoundedCornerShape | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
| import androidx.compose.material.icons.automirrored.filled.CompareArrows | import androidx.compose.material.icons.automirrored.filled.CompareArrows | ||||||
| import androidx.compose.material.icons.filled.* | import androidx.compose.material.icons.filled.* | ||||||
| import androidx.compose.material3.Card | import androidx.compose.material3.Card | ||||||
| import androidx.compose.material3.CardColors |  | ||||||
| import androidx.compose.material3.CardDefaults | import androidx.compose.material3.CardDefaults | ||||||
| import androidx.compose.material3.Icon | import androidx.compose.material3.Icon | ||||||
| import androidx.compose.material3.MaterialTheme | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.NavigationBar | import androidx.compose.material3.NavigationBar | ||||||
| import androidx.compose.material3.NavigationBarItem | import androidx.compose.material3.NavigationBarItem | ||||||
| import androidx.compose.material3.Scaffold | import androidx.compose.material3.Scaffold | ||||||
| import androidx.compose.material3.SegmentedButtonDefaults.Icon |  | ||||||
| import androidx.compose.material3.Text |  | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.Composable | ||||||
| import androidx.compose.runtime.LaunchedEffect | import androidx.compose.runtime.LaunchedEffect | ||||||
| import androidx.compose.runtime.getValue | import androidx.compose.runtime.getValue | ||||||
| import androidx.compose.runtime.mutableStateOf |  | ||||||
| import androidx.compose.runtime.remember |  | ||||||
| import androidx.compose.runtime.setValue |  | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| import androidx.compose.ui.graphics.Color | import androidx.compose.ui.graphics.Color | ||||||
| import androidx.compose.ui.graphics.vector.ImageVector | import androidx.compose.ui.graphics.vector.ImageVector | ||||||
| import androidx.compose.ui.platform.LocalView | import androidx.compose.ui.platform.LocalView | ||||||
| import androidx.compose.ui.res.painterResource |  | ||||||
| import androidx.compose.ui.tooling.preview.Preview |  | ||||||
| import androidx.compose.ui.unit.dp | import androidx.compose.ui.unit.dp | ||||||
| import androidx.core.view.WindowCompat | import androidx.core.view.WindowCompat | ||||||
| import androidx.core.view.WindowInsetsCompat | import androidx.core.view.WindowInsetsCompat | ||||||
| @@ -60,22 +52,19 @@ import androidx.navigation.compose.composable | |||||||
| import androidx.navigation.compose.currentBackStackEntryAsState | import androidx.navigation.compose.currentBackStackEntryAsState | ||||||
| import androidx.navigation.compose.rememberNavController | import androidx.navigation.compose.rememberNavController | ||||||
| import androidx.navigation.navArgument | import androidx.navigation.navArgument | ||||||
| import com.acitelight.aether.view.ComicGridView | import com.acitelight.aether.view.pages.ComicGridView | ||||||
| import com.acitelight.aether.view.ComicPageView | import com.acitelight.aether.view.pages.ComicPageView | ||||||
| import com.acitelight.aether.view.ComicScreen | import com.acitelight.aether.view.pages.ComicScreen | ||||||
| import com.acitelight.aether.view.HomeScreen | import com.acitelight.aether.view.pages.HomeScreen | ||||||
| import com.acitelight.aether.view.MeScreen | import com.acitelight.aether.view.pages.LiveScreen | ||||||
| import com.acitelight.aether.view.TransmissionScreen | import com.acitelight.aether.view.pages.MeScreen | ||||||
| import com.acitelight.aether.view.VideoPlayer | import com.acitelight.aether.view.pages.TransmissionScreen | ||||||
| import com.acitelight.aether.view.VideoScreen | import com.acitelight.aether.view.pages.VideoPlayer | ||||||
|  | import com.acitelight.aether.view.pages.VideoScreen | ||||||
| import dagger.hilt.android.AndroidEntryPoint | import dagger.hilt.android.AndroidEntryPoint | ||||||
| import kotlinx.coroutines.Dispatchers |  | ||||||
| import kotlinx.coroutines.flow.filter | import kotlinx.coroutines.flow.filter | ||||||
| import kotlinx.coroutines.flow.first | import kotlinx.coroutines.flow.first | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
| import kotlinx.coroutines.withContext |  | ||||||
| import okhttp3.OkHttpClient |  | ||||||
| import okhttp3.Request |  | ||||||
|  |  | ||||||
| @AndroidEntryPoint | @AndroidEntryPoint | ||||||
| class MainActivity : ComponentActivity() { | class MainActivity : ComponentActivity() { | ||||||
| @@ -111,12 +100,8 @@ class MainScreenActivity : ComponentActivity() { | |||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| @Composable | fun setFullScreen(view: View, isFullScreen: Boolean) { | ||||||
| fun ToggleFullScreen(isFullScreen: Boolean) |     Global.isFullScreen = isFullScreen | ||||||
| { |  | ||||||
|     val view = LocalView.current |  | ||||||
|  |  | ||||||
|     LaunchedEffect(isFullScreen) { |  | ||||||
|     val window = (view.context as Activity).window |     val window = (view.context as Activity).window | ||||||
|     val insetsController = WindowCompat.getInsetsController(window, view) |     val insetsController = WindowCompat.getInsetsController(window, view) | ||||||
|  |  | ||||||
| @@ -127,7 +112,6 @@ fun ToggleFullScreen(isFullScreen: Boolean) | |||||||
|     } else { |     } else { | ||||||
|         insetsController.show(WindowInsetsCompat.Type.systemBars()) |         insetsController.show(WindowInsetsCompat.Type.systemBars()) | ||||||
|     } |     } | ||||||
|     } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| @Composable | @Composable | ||||||
| @@ -152,40 +136,73 @@ fun AppNavigation() { | |||||||
|             ) { |             ) { | ||||||
|                 BottomNavigationBar(navController = navController) |                 BottomNavigationBar(navController = navController) | ||||||
|             } |             } | ||||||
|             if(shouldShowBottomBar) |  | ||||||
|                 ToggleFullScreen(false) |  | ||||||
|         } |         } | ||||||
|     ) { innerPadding -> |     ) { innerPadding -> | ||||||
|         NavHost( |         NavHost( | ||||||
|             navController = navController, |             navController = navController, | ||||||
|             startDestination = Screen.Me.route, |             startDestination = Screen.Me.route, | ||||||
|             modifier = if(shouldShowBottomBar)Modifier.padding(innerPadding) else Modifier.padding(0.dp) |             modifier = if(!Global.isFullScreen) Modifier.padding(innerPadding) else Modifier.padding(0.dp) | ||||||
|  |         ) { | ||||||
|  |             composable( | ||||||
|  |                 Screen.Home.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) } | ||||||
|             ) { |             ) { | ||||||
|             composable(Screen.Home.route) { |  | ||||||
|                 CardPage(title = "Home") { |                 CardPage(title = "Home") { | ||||||
|                     HomeScreen(navController = navController) |                     HomeScreen(navController = navController) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|             composable(Screen.Video.route) { |             composable(Screen.Video.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) { | ||||||
|                 VideoScreen(navController = navController) |                 VideoScreen(navController = navController) | ||||||
|             } |             } | ||||||
|             composable(Screen.Comic.route) { |             composable(Screen.Comic.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) { | ||||||
|                 CardPage(title = "Comic") { |                 CardPage(title = "Comic") { | ||||||
|                     ComicScreen(navController = navController) |                     ComicScreen(navController = navController) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             composable(Screen.Transmission.route) { |             composable(Screen.Transmission.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) { | ||||||
|                 CardPage(title = "Tasks") { |                 CardPage(title = "Tasks") { | ||||||
|                     TransmissionScreen(navigator = navController) |                     TransmissionScreen(navigator = navController) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|             composable(Screen.Me.route) { |  | ||||||
|                 MeScreen(); |             composable(Screen.Live.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) { | ||||||
|  |                 LiveScreen() | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             composable(Screen.Me.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) { | ||||||
|  |                 MeScreen() | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             composable( |             composable( | ||||||
|                 route = Screen.VideoPlayer.route, |                 route = Screen.VideoPlayer.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|                 arguments = listOf(navArgument("videoId") { type = NavType.StringType }) |                 arguments = listOf(navArgument("videoId") { type = NavType.StringType }) | ||||||
|             ) { |             ) { | ||||||
|                 backStackEntry -> |                 backStackEntry -> | ||||||
| @@ -197,6 +214,10 @@ fun AppNavigation() { | |||||||
|  |  | ||||||
|             composable( |             composable( | ||||||
|                 route = Screen.ComicGrid.route, |                 route = Screen.ComicGrid.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|                 arguments = listOf(navArgument("comicId") { type = NavType.StringType }) |                 arguments = listOf(navArgument("comicId") { type = NavType.StringType }) | ||||||
|             ) { |             ) { | ||||||
|                     backStackEntry -> |                     backStackEntry -> | ||||||
| @@ -208,6 +229,10 @@ fun AppNavigation() { | |||||||
|  |  | ||||||
|             composable( |             composable( | ||||||
|                 route = Screen.ComicPage.route, |                 route = Screen.ComicPage.route, | ||||||
|  |                 enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) }, | ||||||
|  |                 popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|  |                 popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }, | ||||||
|                 arguments = listOf(navArgument("comicId") { type = NavType.StringType }, navArgument("page") { type = NavType.StringType }) |                 arguments = listOf(navArgument("comicId") { type = NavType.StringType }, navArgument("page") { type = NavType.StringType }) | ||||||
|             ) { |             ) { | ||||||
|                     backStackEntry -> |                     backStackEntry -> | ||||||
| @@ -215,7 +240,6 @@ fun AppNavigation() { | |||||||
|                 val page = backStackEntry.arguments?.getString("page") |                 val page = backStackEntry.arguments?.getString("page") | ||||||
|                 if (comicId != null && page != null) { |                 if (comicId != null && page != null) { | ||||||
|                     ComicPageView(comicId = comicId, page = page, navController = navController) |                     ComicPageView(comicId = comicId, page = page, navController = navController) | ||||||
|                     ToggleFullScreen(true) |  | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -232,6 +256,7 @@ fun BottomNavigationBar(navController: NavController) { | |||||||
|         Screen.Video, |         Screen.Video, | ||||||
|         Screen.Comic, |         Screen.Comic, | ||||||
|         Screen.Transmission, |         Screen.Transmission, | ||||||
|  |         Screen.Live, | ||||||
|         Screen.Me |         Screen.Me | ||||||
|     ) else  listOf( |     ) else  listOf( | ||||||
|         Screen.Video, |         Screen.Video, | ||||||
| @@ -296,6 +321,8 @@ sealed class Screen(val route: String, val icon: ImageVector, val title: String) | |||||||
|     data object Comic : Screen("comic_route", Icons.Filled.Image, "Comic") |     data object Comic : Screen("comic_route", Icons.Filled.Image, "Comic") | ||||||
|     data object Transmission : Screen("transmission_route", |     data object Transmission : Screen("transmission_route", | ||||||
|         Icons.AutoMirrored.Filled.CompareArrows, "Transmission") |         Icons.AutoMirrored.Filled.CompareArrows, "Transmission") | ||||||
|  |     data object Live : Screen("live_route", | ||||||
|  |         Icons.Filled.LiveTv, "Live") | ||||||
|     data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me") |     data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me") | ||||||
|     data object VideoPlayer : Screen("video_player_route/{videoId}", Icons.Filled.PlayArrow, "VideoPlayer") |     data object VideoPlayer : Screen("video_player_route/{videoId}", Icons.Filled.PlayArrow, "VideoPlayer") | ||||||
|     data object ComicGrid : Screen("comic_grid_route/{comicId}", Icons.Filled.PlayArrow, "ComicGrid") |     data object ComicGrid : Screen("comic_grid_route/{comicId}", Icons.Filled.PlayArrow, "ComicGrid") | ||||||
|   | |||||||
| @@ -1,5 +1,8 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  |  | ||||||
|  | @Serializable | ||||||
| data class BookMark( | data class BookMark( | ||||||
|     val name: String, |     val name: String, | ||||||
|     val page: String |     val page: String | ||||||
|   | |||||||
| @@ -4,21 +4,33 @@ import com.acitelight.aether.service.ApiClient | |||||||
|  |  | ||||||
| class Comic( | class Comic( | ||||||
|     val comic: ComicResponse, |     val comic: ComicResponse, | ||||||
|     val id: String, |     val id: String | ||||||
|     val token: String |  | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     fun getPage(pageNumber: Int): String |     fun getCover(api: ApiClient): String | ||||||
|     { |     { | ||||||
|         return "${ApiClient.getBase()}api/image/$id/${comic.list[pageNumber]}?token=$token" |         if(id == "101") | ||||||
|  |             print("") | ||||||
|  |  | ||||||
|  |         if(comic.cover != "") | ||||||
|  |         { | ||||||
|  |             return "${api.getBase()}api/image/$id/${comic.cover}" | ||||||
|         } |         } | ||||||
|  |  | ||||||
|     fun getPage(pageName: String): String? |         return "${api.getBase()}api/image/$id/${comic.list[0]}" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getPage(pageNumber: Int, api: ApiClient): String | ||||||
|  |     { | ||||||
|  |         return "${api.getBase()}api/image/$id/${comic.list[pageNumber]}" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getPage(pageName: String, api: ApiClient): String? | ||||||
|     { |     { | ||||||
|         val v = comic.list.indexOf(pageName) |         val v = comic.list.indexOf(pageName) | ||||||
|         if(v >= 0) |         if(v >= 0) | ||||||
|         { |         { | ||||||
|             return getPage(v) |             return getPage(v, api) | ||||||
|         } |         } | ||||||
|         return null |         return null | ||||||
|     } |     } | ||||||
| @@ -33,7 +45,7 @@ class Comic( | |||||||
|         var v = comic.list.indexOf(pageName) |         var v = comic.list.indexOf(pageName) | ||||||
|         if(v >= 0) |         if(v >= 0) | ||||||
|         { |         { | ||||||
|             var r: Int = 1 |             var r = 1 | ||||||
|             v+=1 |             v+=1 | ||||||
|             while(v < comic.list.size && !comic.bookmarks.any{ |             while(v < comic.list.size && !comic.bookmarks.any{ | ||||||
|                 x -> x.page == comic.list[v] |                 x -> x.page == comic.list[v] | ||||||
|   | |||||||
| @@ -1,10 +1,14 @@ | |||||||
| package com.acitelight.aether.model | package com.acitelight.aether.model | ||||||
|  |  | ||||||
|  | import kotlinx.serialization.Serializable | ||||||
|  |  | ||||||
|  | @Serializable | ||||||
| data class ComicResponse( | data class ComicResponse( | ||||||
|     val comic_name: String, |     val comic_name: String, | ||||||
|     val page_count: Int, |     val page_count: Int, | ||||||
|     val bookmarks: List<BookMark>, |     val bookmarks: List<BookMark>, | ||||||
|     val list: List<String>, |     val list: List<String>, | ||||||
|     val tags: List<String>, |     val tags: List<String>, | ||||||
|     val author: String |     val author: String, | ||||||
|  |     val cover: String | ||||||
| ) | ) | ||||||
| @@ -11,31 +11,30 @@ class Video( | |||||||
|     val localBase: String, |     val localBase: String, | ||||||
|     val klass: String, |     val klass: String, | ||||||
|     val id: String, |     val id: String, | ||||||
|     val token: String, |  | ||||||
|     val video: VideoResponse |     val video: VideoResponse | ||||||
| ) { | ) { | ||||||
|     fun getCover(): String { |     fun getCover(api: ApiClient): String { | ||||||
|         return if (isLocal) |         return if (isLocal) | ||||||
|             "$localBase/videos/$klass/$id/cover.jpg" |             "$localBase/videos/$klass/$id/cover.jpg" | ||||||
|         else |         else | ||||||
|             "${ApiClient.getBase()}api/video/$klass/$id/cover?token=$token" |             "${api.getBase()}api/video/$klass/$id/cover" | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun getVideo(): String { |     fun getVideo(api: ApiClient): String { | ||||||
|         return if (isLocal) |         return if (isLocal) | ||||||
|             "$localBase/videos/$klass/$id/video.mp4" |             "$localBase/videos/$klass/$id/video.mp4" | ||||||
|         else |         else | ||||||
|             "${ApiClient.getBase()}api/video/$klass/$id/av?token=$token" |             "${api.getBase()}api/video/$klass/$id/av" | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun getSubtitle(): String { |     fun getSubtitle(api: ApiClient): String { | ||||||
|         return if (isLocal) |         return if (isLocal) | ||||||
|             "$localBase/videos/$klass/$id/subtitle.vtt" |             "$localBase/videos/$klass/$id/subtitle.vtt" | ||||||
|         else |         else | ||||||
|             "${ApiClient.getBase()}api/video/$klass/$id/subtitle?token=$token" |             "${api.getBase()}api/video/$klass/$id/subtitle" | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun getGallery(): List<KeyImage> { |     fun getGallery(api: ApiClient): List<KeyImage> { | ||||||
|         return if (isLocal) |         return if (isLocal) | ||||||
|             video.gallery.map { |             video.gallery.map { | ||||||
|                 KeyImage( |                 KeyImage( | ||||||
| @@ -46,7 +45,7 @@ class Video( | |||||||
|             } else video.gallery.map { |             } else video.gallery.map { | ||||||
|             KeyImage( |             KeyImage( | ||||||
|                 name = it, |                 name = it, | ||||||
|                 url = "${ApiClient.getBase()}api/video/$klass/$id/gallery/$it?token=$token", |                 url = "${api.getBase()}api/video/$klass/$id/gallery/$it", | ||||||
|                 key = "$klass/$id/gallery/$it" |                 key = "$klass/$id/gallery/$it" | ||||||
|             ) |             ) | ||||||
|         } |         } | ||||||
| @@ -59,7 +58,6 @@ class Video( | |||||||
|             localBase = localBase, |             localBase = localBase, | ||||||
|             klass = klass, |             klass = klass, | ||||||
|             id = id, |             id = id, | ||||||
|             token = "", |  | ||||||
|             video = video |             video = video | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ import androidx.compose.runtime.mutableStateOf | |||||||
| import androidx.compose.runtime.setValue | import androidx.compose.runtime.setValue | ||||||
| import com.tonyodev.fetch2.Status | import com.tonyodev.fetch2.Status | ||||||
| 
 | 
 | ||||||
| class DownloadItemState( | class VideoDownloadItemState( | ||||||
|     val id: Int, |     val id: Int, | ||||||
|     fileName: String, |     fileName: String, | ||||||
|     filePath: String, |     filePath: String, | ||||||
| @@ -15,7 +15,8 @@ class DownloadItemState( | |||||||
|     downloadedBytes: Long, |     downloadedBytes: Long, | ||||||
|     totalBytes: Long, |     totalBytes: Long, | ||||||
|     klass: String, |     klass: String, | ||||||
|     vid: String |     vid: String, | ||||||
|  |     val type: String | ||||||
| ) { | ) { | ||||||
|     var fileName by mutableStateOf(fileName) |     var fileName by mutableStateOf(fileName) | ||||||
|     var filePath by mutableStateOf(filePath) |     var filePath by mutableStateOf(filePath) | ||||||
| @@ -8,5 +8,7 @@ import androidx.room.PrimaryKey | |||||||
| data class VideoRecord ( | data class VideoRecord ( | ||||||
|     @PrimaryKey(autoGenerate = false) val id: String = "", |     @PrimaryKey(autoGenerate = false) val id: String = "", | ||||||
|     @ColumnInfo(name = "name") val klass: String = "", |     @ColumnInfo(name = "name") val klass: String = "", | ||||||
|     @ColumnInfo(name = "position") val position: Long |     @ColumnInfo(name = "position") val position: Long, | ||||||
|  |     @ColumnInfo(name = "time") val time: Long, | ||||||
|  |     @ColumnInfo(name = "group") val group: String | ||||||
| ) | ) | ||||||
| @@ -18,7 +18,7 @@ abstract class VideoRecordDatabase : RoomDatabase()  { | |||||||
|                 val instance = Room.databaseBuilder( |                 val instance = Room.databaseBuilder( | ||||||
|                     context.applicationContext, |                     context.applicationContext, | ||||||
|                     VideoRecordDatabase::class.java, |                     VideoRecordDatabase::class.java, | ||||||
|                     "videorecord_database" |                     "videorecords_database" | ||||||
|                 ).build() |                 ).build() | ||||||
|                 INSTANCE = instance |                 INSTANCE = instance | ||||||
|                 instance |                 instance | ||||||
|   | |||||||
| @@ -10,5 +10,6 @@ data class VideoResponse( | |||||||
|     val comment: List<Comment>, |     val comment: List<Comment>, | ||||||
|     val star: Boolean, |     val star: Boolean, | ||||||
|     val like: Int, |     val like: Int, | ||||||
|     val author: String |     val author: String, | ||||||
|  |     val group: String? | ||||||
| ) | ) | ||||||
|   | |||||||
| @@ -1,8 +1,5 @@ | |||||||
| package com.acitelight.aether.service | 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 kotlinx.coroutines.* | ||||||
| import java.io.InputStream | import java.io.InputStream | ||||||
| import java.io.OutputStream | import java.io.OutputStream | ||||||
| @@ -44,7 +41,7 @@ class AbyssStream private constructor( | |||||||
|          * Create and perform handshake on an already-connected socket. |          * Create and perform handshake on an already-connected socket. | ||||||
|          * If privateKeyRaw is provided, it must be 32 bytes. |          * If privateKeyRaw is provided, it must be 32 bytes. | ||||||
|          */ |          */ | ||||||
|         suspend fun create(socket: Socket, privateKeyRaw: ByteArray? = null): AbyssStream = withContext(Dispatchers.IO) { |         suspend fun create(authManager: AuthManager, socket: Socket, privateKeyRaw: ByteArray? = null): AbyssStream = withContext(Dispatchers.IO) { | ||||||
|             if (!socket.isConnected) throw IllegalArgumentException("socket is not connected") |             if (!socket.isConnected) throw IllegalArgumentException("socket is not connected") | ||||||
|             val inStream = socket.getInputStream() |             val inStream = socket.getInputStream() | ||||||
|             val outStream = socket.getOutputStream() |             val outStream = socket.getOutputStream() | ||||||
| @@ -69,7 +66,7 @@ class AbyssStream private constructor( | |||||||
|  |  | ||||||
|             val ch = ByteArray(32) |             val ch = ByteArray(32) | ||||||
|             readExact(inStream, ch, 0, 32) |             readExact(inStream, ch, 0, 32) | ||||||
|             val signed = signChallengeByte(localPriv, ch) |             val signed = authManager.signChallengeByte(localPriv, ch) | ||||||
|             writeExact(outStream, signed, 0, signed.size) |             writeExact(outStream, signed, 0, signed.size) | ||||||
|             readExact(inStream, ch, 0, 16) |             readExact(inStream, ch, 0, 16) | ||||||
|  |  | ||||||
| @@ -222,7 +219,7 @@ class AbyssStream private constructor( | |||||||
|         val header = ByteArray(4) |         val header = ByteArray(4) | ||||||
|         try { |         try { | ||||||
|             readExact(input, header, 0, 4) |             readExact(input, header, 0, 4) | ||||||
|         } catch (e: EOFException) { |         } catch (_: EOFException) { | ||||||
|             return null |             return null | ||||||
|         } |         } | ||||||
|         val payloadLen = ByteBuffer.wrap(header).int and 0xffffffff.toInt() |         val payloadLen = ByteBuffer.wrap(header).int and 0xffffffff.toInt() | ||||||
|   | |||||||
| @@ -1,8 +1,5 @@ | |||||||
| package com.acitelight.aether.service | package com.acitelight.aether.service | ||||||
|  |  | ||||||
|  |  | ||||||
| import android.util.Log |  | ||||||
| import com.acitelight.aether.service.AuthManager.db64 |  | ||||||
| import kotlinx.coroutines.* | import kotlinx.coroutines.* | ||||||
| import kotlinx.coroutines.flow.first | import kotlinx.coroutines.flow.first | ||||||
| import kotlinx.coroutines.selects.select | import kotlinx.coroutines.selects.select | ||||||
| @@ -17,7 +14,8 @@ import kotlin.coroutines.CoroutineContext | |||||||
|  |  | ||||||
| @Singleton | @Singleton | ||||||
| class AbyssTunnelProxy @Inject constructor( | class AbyssTunnelProxy @Inject constructor( | ||||||
|     private val settingsDataStoreManager: SettingsDataStoreManager |     private val settingsDataStoreManager: SettingsDataStoreManager, | ||||||
|  |     private val authManager: AuthManager | ||||||
| ) { | ) { | ||||||
|     private val coroutineContext: CoroutineContext = Dispatchers.IO |     private val coroutineContext: CoroutineContext = Dispatchers.IO | ||||||
|     private var serverHost: String = "" |     private var serverHost: String = "" | ||||||
| @@ -48,7 +46,7 @@ class AbyssTunnelProxy @Inject constructor( | |||||||
|  |  | ||||||
|                     launch { |                     launch { | ||||||
|                         try { handleLocalConnection(client) } |                         try { handleLocalConnection(client) } | ||||||
|                         catch (ex: Exception) { /* ignore */ } |                         catch (_: Exception) { /* ignore */ } | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } catch (ex: Exception) { |             } catch (ex: Exception) { | ||||||
| @@ -72,14 +70,14 @@ class AbyssTunnelProxy @Inject constructor( | |||||||
|         var abyssStream: AbyssStream? = null |         var abyssStream: AbyssStream? = null | ||||||
|         try { |         try { | ||||||
|             abyssSocket = Socket(serverHost, serverPort) |             abyssSocket = Socket(serverHost, serverPort) | ||||||
|             abyssStream = AbyssStream.create(abyssSocket, db64(settingsDataStoreManager.privateKeyFlow.first())) |             abyssStream = AbyssStream.create(authManager, abyssSocket, authManager.db64(settingsDataStoreManager.privateKeyFlow.first())) | ||||||
|  |  | ||||||
|             // concurrently copy in both directions |             // concurrently copy in both directions | ||||||
|             val job1 = launch { copyExactSuspend(localIn, abyssStream) }   // local -> abyss |             val job1 = launch { copyExactSuspend(localIn, abyssStream) }   // local -> abyss | ||||||
|             val job2 = launch { copyFromAbyssToLocal(abyssStream, localOut) } // abyss -> local |             val job2 = launch { copyFromAbyssToLocal(abyssStream, localOut) } // abyss -> local | ||||||
|  |  | ||||||
|             // wait for either direction to finish |             // wait for either direction to finish | ||||||
|             select<Unit> { |             select { | ||||||
|                 job1.onJoin { /* completed */ } |                 job1.onJoin { /* completed */ } | ||||||
|                 job2.onJoin { /* completed */ } |                 job2.onJoin { /* completed */ } | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -5,18 +5,27 @@ import android.content.Context | |||||||
| import android.util.Log | import android.util.Log | ||||||
| import androidx.core.net.toUri | import androidx.core.net.toUri | ||||||
| import com.acitelight.aether.AetherApp | import com.acitelight.aether.AetherApp | ||||||
|  | import com.franmontiel.persistentcookiejar.PersistentCookieJar | ||||||
|  | import com.franmontiel.persistentcookiejar.cache.SetCookieCache | ||||||
|  | import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor | ||||||
| import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory | import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory | ||||||
|  | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import kotlinx.coroutines.Dispatchers | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.withContext | import kotlinx.coroutines.withContext | ||||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||||
| import okhttp3.ConnectionSpec | import okhttp3.ConnectionSpec | ||||||
|  | import okhttp3.Cookie | ||||||
|  | import okhttp3.CookieJar | ||||||
| import okhttp3.EventListener | import okhttp3.EventListener | ||||||
|  | import okhttp3.HttpUrl | ||||||
| import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | import okhttp3.HttpUrl.Companion.toHttpUrlOrNull | ||||||
|  | import okhttp3.JavaNetCookieJar | ||||||
| import okhttp3.MediaType.Companion.toMediaType | import okhttp3.MediaType.Companion.toMediaType | ||||||
| import okhttp3.OkHttpClient | import okhttp3.OkHttpClient | ||||||
| import retrofit2.Retrofit | import retrofit2.Retrofit | ||||||
| import retrofit2.converter.gson.GsonConverterFactory | import retrofit2.converter.gson.GsonConverterFactory | ||||||
| import java.io.ByteArrayInputStream | import java.io.ByteArrayInputStream | ||||||
|  | import java.net.CookieManager | ||||||
| import java.net.InetAddress | import java.net.InetAddress | ||||||
| import java.net.InetSocketAddress | import java.net.InetSocketAddress | ||||||
| import java.net.Proxy | import java.net.Proxy | ||||||
| @@ -24,26 +33,31 @@ import java.security.KeyStore | |||||||
| import java.security.cert.CertificateException | import java.security.cert.CertificateException | ||||||
| import java.security.cert.CertificateFactory | import java.security.cert.CertificateFactory | ||||||
| import java.security.cert.X509Certificate | import java.security.cert.X509Certificate | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
| import javax.net.ssl.SSLContext | import javax.net.ssl.SSLContext | ||||||
| import javax.net.ssl.TrustManagerFactory | import javax.net.ssl.TrustManagerFactory | ||||||
| import javax.net.ssl.X509TrustManager | import javax.net.ssl.X509TrustManager | ||||||
|  |  | ||||||
|  | @Singleton | ||||||
| object ApiClient { | class ApiClient @Inject constructor( | ||||||
|  |     @ApplicationContext private val context: Context, | ||||||
|  | ) { | ||||||
|     fun getBase(): String{ |     fun getBase(): String{ | ||||||
|         return replaceAbyssProtocol(base) |         return replaceAbyssProtocol(base) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun getDomain(): String = domain | ||||||
|  |  | ||||||
|     private var base: String = "" |     private var base: String = "" | ||||||
|     var domain: String = "" |     private var domain: String = "" | ||||||
|     var cert: String = "" |     private var cert: String = "" | ||||||
|     private val json = Json { |     private val json = Json { | ||||||
|         ignoreUnknownKeys = true |         ignoreUnknownKeys = true | ||||||
|     } |     } | ||||||
|  |     private fun replaceAbyssProtocol(uri: String): String { | ||||||
|     fun replaceAbyssProtocol(uri: String): String { |  | ||||||
|         return uri.replaceFirst("^abyss://".toRegex(), "https://") |         return uri.replaceFirst("^abyss://".toRegex(), "https://") | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private val dnsEventListener = object : EventListener() { |     private val dnsEventListener = object : EventListener() { | ||||||
|         override fun dnsEnd(call: okhttp3.Call, domainName: String, inetAddressList: List<InetAddress>) { |         override fun dnsEnd(call: okhttp3.Call, domainName: String, inetAddressList: List<InetAddress>) { | ||||||
|             super.dnsEnd(call, domainName, inetAddressList) |             super.dnsEnd(call, domainName, inetAddressList) | ||||||
| @@ -51,8 +65,7 @@ object ApiClient { | |||||||
|             Log.d("OkHttp_DNS", "Domain '$domainName' resolved to IPs: [$ipAddresses]") |             Log.d("OkHttp_DNS", "Domain '$domainName' resolved to IPs: [$ipAddresses]") | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |     private fun loadCertificateFromString(pemString: String): X509Certificate { | ||||||
|     fun loadCertificateFromString(pemString: String): X509Certificate { |  | ||||||
|         val certificateFactory = CertificateFactory.getInstance("X.509") |         val certificateFactory = CertificateFactory.getInstance("X.509") | ||||||
|         val decodedPem = pemString |         val decodedPem = pemString | ||||||
|             .replace("-----BEGIN CERTIFICATE-----", "") |             .replace("-----BEGIN CERTIFICATE-----", "") | ||||||
| @@ -65,8 +78,7 @@ object ApiClient { | |||||||
|             return certificateFactory.generateCertificate(inputStream) as X509Certificate |             return certificateFactory.generateCertificate(inputStream) as X509Certificate | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |     private fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate?): OkHttpClient { | ||||||
|     fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate?): OkHttpClient { |  | ||||||
|         try { |         try { | ||||||
|             val defaultTmFactory = TrustManagerFactory.getInstance( |             val defaultTmFactory = TrustManagerFactory.getInstance( | ||||||
|                 TrustManagerFactory.getDefaultAlgorithm() |                 TrustManagerFactory.getDefaultAlgorithm() | ||||||
| @@ -86,7 +98,7 @@ object ApiClient { | |||||||
|                 ).apply { |                 ).apply { | ||||||
|                     init(keyStore) |                     init(keyStore) | ||||||
|                 } |                 } | ||||||
|                 tmf.trustManagers.first { it is X509TrustManager } as X509TrustManager |                 tmf.trustManagers.first { i -> i is X509TrustManager } as X509TrustManager | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             val combinedTm = object : X509TrustManager { |             val combinedTm = object : X509TrustManager { | ||||||
| @@ -156,12 +168,17 @@ object ApiClient { | |||||||
|             throw RuntimeException("Failed to create OkHttpClient with dynamic certificate", e) |             throw RuntimeException("Failed to create OkHttpClient with dynamic certificate", e) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |     private fun createOkHttp(): OkHttpClient { | ||||||
|     fun createOkHttp(): OkHttpClient { |  | ||||||
|         return if (cert == "") |         return if (cert == "") | ||||||
|             if (base.startsWith("abyss://")) |             if (base.startsWith("abyss://")) | ||||||
|                 OkHttpClient |                 OkHttpClient | ||||||
|                     .Builder() |                     .Builder() | ||||||
|  |                     .cookieJar( | ||||||
|  |                         PersistentCookieJar( | ||||||
|  |                             SetCookieCache(), | ||||||
|  |                             SharedPrefsCookiePersistor(context) | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|                     .proxy( |                     .proxy( | ||||||
|                         Proxy( |                         Proxy( | ||||||
|                             Proxy.Type.HTTP, |                             Proxy.Type.HTTP, | ||||||
| @@ -174,6 +191,12 @@ object ApiClient { | |||||||
|             else |             else | ||||||
|                 OkHttpClient |                 OkHttpClient | ||||||
|                     .Builder() |                     .Builder() | ||||||
|  |                     .cookieJar( | ||||||
|  |                         PersistentCookieJar( | ||||||
|  |                             SetCookieCache(), | ||||||
|  |                             SharedPrefsCookiePersistor(context) | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|                     .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS)) |                     .connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS)) | ||||||
|                     .eventListener(dnsEventListener) |                     .eventListener(dnsEventListener) | ||||||
|                     .build() |                     .build() | ||||||
| @@ -181,24 +204,26 @@ object ApiClient { | |||||||
|             createOkHttpClientWithDynamicCert(loadCertificateFromString(cert)) |             createOkHttpClientWithDynamicCert(loadCertificateFromString(cert)) | ||||||
|  |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun createRetrofit(): Retrofit { |     private fun createRetrofit(): Retrofit { | ||||||
|         val okHttpClient = createOkHttp() |         client = createOkHttp() | ||||||
|         val b = replaceAbyssProtocol(base) |         val b = replaceAbyssProtocol(base) | ||||||
|  |  | ||||||
|         return Retrofit.Builder() |         return Retrofit.Builder() | ||||||
|             .baseUrl(b) |             .baseUrl(b) | ||||||
|             .client(okHttpClient) |             .client(client!!) | ||||||
|             .addConverterFactory(GsonConverterFactory.create()) |             .addConverterFactory(GsonConverterFactory.create()) | ||||||
|             .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) |             .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) | ||||||
|             .build() |             .build() | ||||||
|     } |     } | ||||||
|  |     private var client: OkHttpClient? = null | ||||||
|  |  | ||||||
|     var api: ApiInterface? = null |     var api: ApiInterface? = null | ||||||
|  |  | ||||||
|  |     fun getClient() = client!! | ||||||
|  |  | ||||||
|     suspend fun apply(context: Context, urls: String, crt: String): String? { |     suspend fun apply(context: Context, urls: String, crt: String): String? { | ||||||
|         try { |         try { | ||||||
|  |             client = createOkHttp() | ||||||
|  |  | ||||||
|             val urlList = urls.split(";").map { it.trim() } |             val urlList = urls.split(";").map { it.trim() } | ||||||
|  |  | ||||||
|             var selectedUrl: String? = null |             var selectedUrl: String? = null | ||||||
| @@ -214,21 +239,16 @@ object ApiClient { | |||||||
|                 throw Exception("No reachable URL found") |                 throw Exception("No reachable URL found") | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             domain = selectedUrl.toHttpUrlOrNull()?.host ?: "" |             domain = replaceAbyssProtocol(selectedUrl).toHttpUrlOrNull()?.host ?: "" | ||||||
|             cert = crt |             cert = crt | ||||||
|             base = selectedUrl |             base = selectedUrl | ||||||
|             withContext(Dispatchers.IO) |             withContext(Dispatchers.IO) | ||||||
|             { |             { | ||||||
|                 (context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096) |                 (context as AetherApp).abyssService?.proxy?.config(getBase().toUri().host!!, 4096) | ||||||
|             } |             } | ||||||
|             api = createRetrofit().create(ApiInterface::class.java) |             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 |             return base | ||||||
|         } catch (e: Exception) { |         } catch (_: Exception) { | ||||||
|             api = null |             api = null | ||||||
|             base = "" |             base = "" | ||||||
|             domain = "" |             domain = "" | ||||||
| @@ -241,7 +261,7 @@ object ApiClient { | |||||||
|         return@withContext try { |         return@withContext try { | ||||||
|             val address = InetAddress.getByName(host) |             val address = InetAddress.getByName(host) | ||||||
|             address.isReachable(200) |             address.isReachable(200) | ||||||
|         } catch (e: Exception) { |         } catch (_: Exception) { | ||||||
|             false |             false | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -4,49 +4,43 @@ import com.acitelight.aether.model.BookMark | |||||||
| import com.acitelight.aether.model.ChallengeResponse | import com.acitelight.aether.model.ChallengeResponse | ||||||
| import com.acitelight.aether.model.ComicResponse | import com.acitelight.aether.model.ComicResponse | ||||||
| import com.acitelight.aether.model.VideoResponse | import com.acitelight.aether.model.VideoResponse | ||||||
| import okhttp3.Response |  | ||||||
| import okhttp3.ResponseBody | import okhttp3.ResponseBody | ||||||
| import retrofit2.http.Body | import retrofit2.http.Body | ||||||
| import retrofit2.http.GET | import retrofit2.http.GET | ||||||
| import retrofit2.http.POST | import retrofit2.http.POST | ||||||
| import retrofit2.http.Path | import retrofit2.http.Path | ||||||
| import retrofit2.http.Query |  | ||||||
| import retrofit2.http.Streaming |  | ||||||
|  |  | ||||||
| interface ApiInterface { | interface ApiInterface { | ||||||
|     @GET("api/video") |     @GET("api/video") | ||||||
|     suspend fun getVideoClasses( |     suspend fun getVideoClasses( | ||||||
|         @Query("token") token: String |  | ||||||
|     ): List<String> |     ): List<String> | ||||||
|     @GET("api/video/{klass}") |     @GET("api/video/{klass}") | ||||||
|     suspend fun queryVideoClasses( |     suspend fun queryVideoClasses( | ||||||
|         @Path("klass") klass: String, |         @Path("klass") klass: String | ||||||
|         @Query("token") token: String |  | ||||||
|     ): List<String> |     ): List<String> | ||||||
|     @GET("api/video/{klass}/{id}") |     @GET("api/video/{klass}/{id}") | ||||||
|     suspend fun queryVideo( |     suspend fun queryVideo( | ||||||
|         @Path("klass") klass: String, |         @Path("klass") klass: String, | ||||||
|         @Path("id") id: String, |         @Path("id") id: String | ||||||
|         @Query("token") token: String |  | ||||||
|     ): VideoResponse |     ): VideoResponse | ||||||
|  |  | ||||||
|     @POST("api/video/{klass}/bulkquery") |     @POST("api/video/{klass}/bulkquery") | ||||||
|     suspend fun queryVideoBulk( |     suspend fun queryVideoBulk( | ||||||
|         @Path("klass") klass: String, |         @Path("klass") klass: String, | ||||||
|         @Body() id: List<String>, |         @Body() id: List<String> | ||||||
|         @Query("token") token: String |  | ||||||
|     ): List<VideoResponse> |     ): List<VideoResponse> | ||||||
|  |  | ||||||
|     @GET("api/image") |     @GET("api/image") | ||||||
|     suspend fun getComics(@Query("token") token: String): List<String> |     suspend fun getComics(): List<String> | ||||||
|     @GET("api/image/{id}") |     @GET("api/image/{id}") | ||||||
|     suspend fun queryComicInfo(@Path("id") id: String, @Query("token") token: String): ComicResponse |     suspend fun queryComicInfo(@Path("id") id: String): ComicResponse | ||||||
|  |  | ||||||
|     @POST("api/image/bulkquery") |     @POST("api/image/bulkquery") | ||||||
|     suspend fun queryComicInfoBulk(@Body() id: List<String>, @Query("token") token: String): List<ComicResponse> |     suspend fun queryComicInfoBulk(@Body() id: List<String>): List<ComicResponse> | ||||||
|  |  | ||||||
|     @POST("api/image/{id}/bookmark") |     @POST("api/image/{id}/bookmark") | ||||||
|     suspend fun postBookmark(@Path("id") id: String, @Query("token") token: String, @Body bookmark: BookMark) |     suspend fun postBookmark(@Path("id") id: String, @Body bookmark: BookMark) | ||||||
|  |  | ||||||
|     @GET("api/user/{user}") |     @GET("api/user/{user}") | ||||||
|     suspend fun getChallenge( |     suspend fun getChallenge( | ||||||
|   | |||||||
| @@ -18,10 +18,15 @@ import java.lang.reflect.Proxy | |||||||
| import java.net.InetSocketAddress | import java.net.InetSocketAddress | ||||||
| import java.security.PrivateKey | import java.security.PrivateKey | ||||||
| import java.security.Signature | import java.security.Signature | ||||||
|  | import javax.inject.Inject | ||||||
|  | import javax.inject.Singleton | ||||||
|  |  | ||||||
| object AuthManager { | @Singleton | ||||||
|  | class AuthManager @Inject constructor( | ||||||
|  |     private val apiClient: ApiClient | ||||||
|  | ) { | ||||||
|     suspend fun fetchToken(username: String, privateKey: String): String? { |     suspend fun fetchToken(username: String, privateKey: String): String? { | ||||||
|         val api = ApiClient.api |         val api = apiClient.api | ||||||
|         var challengeBase64 = "" |         var challengeBase64 = "" | ||||||
|  |  | ||||||
|         try{ |         try{ | ||||||
|   | |||||||
| @@ -1,49 +1,37 @@ | |||||||
| package com.acitelight.aether.service | package com.acitelight.aether.service | ||||||
|  |  | ||||||
| import android.content.Context | import android.content.Context | ||||||
| import androidx.compose.runtime.mutableStateOf |  | ||||||
| import com.acitelight.aether.Screen |  | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import com.acitelight.aether.service.ApiClient.createOkHttp |  | ||||||
| import com.tonyodev.fetch2.Download | import com.tonyodev.fetch2.Download | ||||||
| import com.tonyodev.fetch2.Fetch | import com.tonyodev.fetch2.Fetch | ||||||
| import com.tonyodev.fetch2.FetchConfiguration | import com.tonyodev.fetch2.FetchConfiguration | ||||||
| import com.tonyodev.fetch2.FetchListener | import com.tonyodev.fetch2.FetchListener | ||||||
| import com.tonyodev.fetch2.Request | import com.tonyodev.fetch2.Request | ||||||
| import com.tonyodev.fetch2.Status |  | ||||||
| import com.tonyodev.fetch2core.Extras | import com.tonyodev.fetch2core.Extras | ||||||
| import com.tonyodev.fetch2okhttp.OkHttpDownloader | import com.tonyodev.fetch2okhttp.OkHttpDownloader | ||||||
| import dagger.hilt.android.qualifiers.ApplicationContext | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import kotlinx.coroutines.Dispatchers |  | ||||||
| import kotlinx.coroutines.flow.MutableStateFlow | import kotlinx.coroutines.flow.MutableStateFlow | ||||||
| import kotlinx.coroutines.flow.StateFlow |  | ||||||
| import kotlinx.coroutines.flow.asStateFlow |  | ||||||
| import kotlinx.coroutines.flow.filter | import kotlinx.coroutines.flow.filter | ||||||
| import kotlinx.coroutines.flow.first | import kotlinx.coroutines.flow.first | ||||||
| import kotlinx.coroutines.flow.update | import kotlinx.coroutines.flow.update | ||||||
| import kotlinx.coroutines.withContext |  | ||||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||||
| import okhttp3.OkHttpClient |  | ||||||
| import java.io.File | import java.io.File | ||||||
| import java.io.IOException |  | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
| import javax.inject.Singleton | import javax.inject.Singleton | ||||||
|  |  | ||||||
| @Singleton | @Singleton | ||||||
| class FetchManager @Inject constructor( | class FetchManager @Inject constructor( | ||||||
|     @ApplicationContext private val context: Context |     @ApplicationContext private val context: Context, | ||||||
|  |     private val apiClient: ApiClient | ||||||
| ) { | ) { | ||||||
|     private var fetch: Fetch? = null |     private var fetch: Fetch? = null | ||||||
|     private var listener: FetchListener? = null |     private var listener: FetchListener? = null | ||||||
|     private var client: OkHttpClient? = null |  | ||||||
|     val configured = MutableStateFlow(false) |     val configured = MutableStateFlow(false) | ||||||
|  |  | ||||||
|     fun init() |     fun init() { | ||||||
|     { |  | ||||||
|         client = createOkHttp() |  | ||||||
|         val fetchConfiguration = FetchConfiguration.Builder(context) |         val fetchConfiguration = FetchConfiguration.Builder(context) | ||||||
|             .setDownloadConcurrentLimit(8) |             .setDownloadConcurrentLimit(8) | ||||||
|             .setHttpDownloader(OkHttpDownloader(client)) |             .setHttpDownloader(OkHttpDownloader(apiClient.getClient())) | ||||||
|             .build() |             .build() | ||||||
|  |  | ||||||
|         fetch = Fetch.Impl.getInstance(fetchConfiguration) |         fetch = Fetch.Impl.getInstance(fetchConfiguration) | ||||||
| @@ -66,14 +54,7 @@ class FetchManager @Inject constructor( | |||||||
|         listener = null |         listener = null | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     // query downloads |     suspend fun getAllDownloadsAsync(): List<Download> { | ||||||
|     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() |         configured.filter { it }.first() | ||||||
|         val completed = MutableStateFlow(false) |         val completed = MutableStateFlow(false) | ||||||
|         var r = listOf<Download>() |         var r = listOf<Download>() | ||||||
| @@ -96,6 +77,10 @@ class FetchManager @Inject constructor( | |||||||
|         fetch?.resume(id) |         fetch?.resume(id) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun retry(id: Int) { | ||||||
|  |         fetch?.retry(id) | ||||||
|  |     } | ||||||
|  |  | ||||||
|     fun cancel(id: Int) { |     fun cancel(id: Int) { | ||||||
|         fetch?.cancel(id) |         fetch?.cancel(id) | ||||||
|     } |     } | ||||||
| @@ -106,71 +91,92 @@ class FetchManager @Inject constructor( | |||||||
|         } ?: callback?.invoke() |         } ?: callback?.invoke() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private suspend fun enqueue(request: Request, onEnqueued: ((Request) -> Unit)? = null, onError: ((com.tonyodev.fetch2.Error) -> Unit)? = null) { |     private suspend fun enqueue( | ||||||
|  |         request: Request, | ||||||
|  |         onEnqueued: ((Request) -> Unit)? = null, | ||||||
|  |         onError: ((com.tonyodev.fetch2.Error) -> Unit)? = null | ||||||
|  |     ) { | ||||||
|         configured.filter { it }.first() |         configured.filter { it }.first() | ||||||
|         fetch?.enqueue(request, { r -> onEnqueued?.invoke(r) }, { e -> onError?.invoke(e) }) |         fetch?.enqueue(request, { r -> onEnqueued?.invoke(r) }, { e -> onError?.invoke(e) }) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun getVideosDirectory() { |     private fun makeFolder(video: Video) { | ||||||
|         val appFilesDir = context.getExternalFilesDir(null) |         val appFilesDir = context.getExternalFilesDir(null) | ||||||
|         val videosDir = File(appFilesDir, "videos") |         val videosDir = File(appFilesDir, "videos/${video.klass}/${video.id}/gallery") | ||||||
|  |         videosDir.mkdirs() | ||||||
|         if (!videosDir.exists()) { |  | ||||||
|             val created = videosDir.mkdirs() |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun downloadFile( |     suspend fun startVideoDownload(video: Video) { | ||||||
|         client: OkHttpClient, |         if(getAllDownloadsAsync().any{ | ||||||
|         url: String, |             it.extras.getString("class", "") == video.klass && it.extras.getString("id", "") == video.id }) | ||||||
|         destFile: File |             return | ||||||
|     ): Result<Unit> = withContext(Dispatchers.IO) { |  | ||||||
|         try { |         makeFolder(video) | ||||||
|             val request = okhttp3.Request.Builder().url(url).build() |         File( | ||||||
|             client.newCall(request).execute().use { response -> |             context.getExternalFilesDir(null), | ||||||
|                 if (!response.isSuccessful) { |             "videos/${video.klass}/${video.id}/summary.json" | ||||||
|                     return@withContext Result.failure(IOException("Unexpected code $response")) |         ).writeText(Json.encodeToString(video)) | ||||||
|  |  | ||||||
|  |         val videoPath = | ||||||
|  |             File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/video.mp4") | ||||||
|  |         val coverPath = | ||||||
|  |             File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/cover.jpg") | ||||||
|  |         val subtitlePath = File( | ||||||
|  |             context.getExternalFilesDir(null), | ||||||
|  |             "videos/${video.klass}/${video.id}/subtitle.vtt" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         val requests = mutableListOf( | ||||||
|  |             Request(video.getVideo(apiClient), videoPath.path).apply { | ||||||
|  |                 extras = Extras( | ||||||
|  |                     mapOf( | ||||||
|  |                         "name" to video.video.name, | ||||||
|  |                         "id" to video.id, | ||||||
|  |                         "class" to video.klass, | ||||||
|  |                         "type" to "main" | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             }, | ||||||
|  |             Request(video.getCover(apiClient), coverPath.path).apply { | ||||||
|  |                 extras = Extras( | ||||||
|  |                     mapOf( | ||||||
|  |                         "name" to video.video.name, | ||||||
|  |                         "id" to video.id, | ||||||
|  |                         "class" to video.klass, | ||||||
|  |                         "type" to "cover" | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             }, | ||||||
|  |             Request(video.getSubtitle(apiClient), subtitlePath.path).apply { | ||||||
|  |                 extras = Extras( | ||||||
|  |                     mapOf( | ||||||
|  |                         "name" to video.video.name, | ||||||
|  |                         "id" to video.id, | ||||||
|  |                         "class" to video.klass, | ||||||
|  |                         "type" to "subtitle" | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         for (p in video.getGallery(apiClient)) { | ||||||
|  |             requests.add( | ||||||
|  |                 Request(p.url, File( | ||||||
|  |                     context.getExternalFilesDir(null), | ||||||
|  |                     "videos/${video.klass}/${video.id}/gallery/${p.name}" | ||||||
|  |                 ).path).apply { | ||||||
|  |                     extras = Extras( | ||||||
|  |                         mapOf( | ||||||
|  |                             "name" to video.video.name, | ||||||
|  |                             "id" to video.id, | ||||||
|  |                             "class" to video.klass, | ||||||
|  |                             "type" to "gallery" | ||||||
|  |                         ) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             ) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|                 destFile.parentFile?.mkdirs() |         for (i in requests) | ||||||
|                 response.body.byteStream().use { input -> |             enqueue(i) | ||||||
|                     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}")) |  | ||||||
|         } |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -3,8 +3,8 @@ package com.acitelight.aether.service | |||||||
| import android.content.Context | import android.content.Context | ||||||
| import com.acitelight.aether.model.BookMark | import com.acitelight.aether.model.BookMark | ||||||
| import com.acitelight.aether.model.Comic | import com.acitelight.aether.model.Comic | ||||||
| import com.acitelight.aether.model.ComicResponse |  | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.model.VideoDownloadItemState | ||||||
| import com.tonyodev.fetch2.Status | import com.tonyodev.fetch2.Status | ||||||
| import dagger.hilt.android.qualifiers.ApplicationContext | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||||
| @@ -16,18 +16,17 @@ import javax.inject.Singleton | |||||||
| @Singleton | @Singleton | ||||||
| class MediaManager @Inject constructor( | class MediaManager @Inject constructor( | ||||||
|     val fetchManager: FetchManager, |     val fetchManager: FetchManager, | ||||||
|     @ApplicationContext val context: Context |     @ApplicationContext val context: Context, | ||||||
|  |     private val apiClient: ApiClient | ||||||
| ) | ) | ||||||
| { | { | ||||||
|     var token: String = "null" |  | ||||||
|  |  | ||||||
|     suspend fun listVideoKlasses(): List<String> |     suspend fun listVideoKlasses(): List<String> | ||||||
|     { |     { | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             val j = ApiClient.api!!.getVideoClasses(token) |             val j = apiClient.api!!.getVideoClasses() | ||||||
|             return j.toList() |             return j.toList() | ||||||
|         }catch(e: Exception) |         }catch(_: Exception) | ||||||
|         { |         { | ||||||
|             return listOf() |             return listOf() | ||||||
|         } |         } | ||||||
| @@ -37,23 +36,17 @@ class MediaManager @Inject constructor( | |||||||
|     { |     { | ||||||
|         try |         try | ||||||
|         { |         { | ||||||
|             val j = ApiClient.api!!.queryVideoClasses(klass, token) |             val j = apiClient.api!!.queryVideoClasses(klass) | ||||||
|             return j.toList() |             return j.toList() | ||||||
|         }catch(e: Exception) |         }catch(_: Exception) | ||||||
|         { |         { | ||||||
|             return listOf() |             return listOf() | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     suspend fun queryVideo(klass: String, id: String): Video? |     suspend fun queryVideo(klass: String, id: String, model: VideoDownloadItemState): Video? | ||||||
|     { |     { | ||||||
|         val downloaded = fetchManager.getAllDownloadsAsync().filter { |         if(model.status == Status.COMPLETED) | ||||||
|             it.status == Status.COMPLETED && |  | ||||||
|             it.extras.getString("id", "") == id && |  | ||||||
|             it.extras.getString("class", "") == klass |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         if(!downloaded.isEmpty()) |  | ||||||
|         { |         { | ||||||
|             val jsonString = File( |             val jsonString = File( | ||||||
|                 context.getExternalFilesDir(null), |                 context.getExternalFilesDir(null), | ||||||
| @@ -63,9 +56,35 @@ class MediaManager @Inject constructor( | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         try { |         try { | ||||||
|             val j = ApiClient.api!!.queryVideo(klass, id, token) |             val j = apiClient.api!!.queryVideo(klass, id) | ||||||
|             return Video(klass = klass, id = id, token=token, isLocal = false, localBase = "", video = j) |             return Video(klass = klass, id = id, isLocal = false, localBase = "", video = j) | ||||||
|         }catch (e: Exception) |         }catch (_: Exception) | ||||||
|  |         { | ||||||
|  |             return null | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     suspend fun queryVideo(klass: String, id: String): Video? | ||||||
|  |     { | ||||||
|  |         val downloaded = fetchManager.getAllDownloadsAsync().filter { | ||||||
|  |             it.extras.getString("id", "") == id && | ||||||
|  |             it.extras.getString("class", "") == klass | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if(downloaded.any{ it.status == Status.COMPLETED } | ||||||
|  |             && downloaded.all{ it.status == Status.COMPLETED || it.extras.getString("type", "") == "subtitle" }) | ||||||
|  |         { | ||||||
|  |             val jsonString = File( | ||||||
|  |                 context.getExternalFilesDir(null), | ||||||
|  |                 "videos/$klass/$id/summary.json" | ||||||
|  |             ).readText() | ||||||
|  |             return Json.decodeFromString<Video>(jsonString).toLocal(context.getExternalFilesDir(null)?.path!!) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         try { | ||||||
|  |             val j = apiClient.api!!.queryVideo(klass, id) | ||||||
|  |             return Video(klass = klass, id = id, isLocal = false, localBase = "", video = j) | ||||||
|  |         }catch (_: Exception) | ||||||
|         { |         { | ||||||
|             return null |             return null | ||||||
|         } |         } | ||||||
| @@ -73,16 +92,20 @@ class MediaManager @Inject constructor( | |||||||
|  |  | ||||||
|     suspend fun queryVideoBulk(klass: String, id: List<String>): List<Video>? { |     suspend fun queryVideoBulk(klass: String, id: List<String>): List<Video>? { | ||||||
|         return try { |         return try { | ||||||
|             val completedDownloads = fetchManager.getAllDownloadsAsync() |             val downloads = fetchManager.getAllDownloadsAsync() | ||||||
|                 .filter { it.status == Status.COMPLETED } |  | ||||||
|             val localIds = mutableSetOf<String>() |             val localIds = mutableSetOf<String>() | ||||||
|             val remoteIds = mutableListOf<String>() |             val remoteIds = mutableListOf<String>() | ||||||
|  |  | ||||||
|             for (videoId in id) { |             for (videoId in id) { | ||||||
|                 if (completedDownloads.any { |                 val o = downloads.filter { | ||||||
|                     it.extras.getString("id", "") == videoId && |                     it.extras.getString("id", "") == videoId && | ||||||
|                             it.extras.getString("class", "") == klass |                             it.extras.getString("class", "") == klass | ||||||
|                     }) { |                 } | ||||||
|  |  | ||||||
|  |                 if (o.any{ it.status == Status.COMPLETED } | ||||||
|  |                     && o.all{ it.status == Status.COMPLETED || it.extras.getString("type", "") == "subtitle" }) | ||||||
|  |                 { | ||||||
|                     localIds.add(videoId) |                     localIds.add(videoId) | ||||||
|                 } else { |                 } else { | ||||||
|                     remoteIds.add(videoId) |                     remoteIds.add(videoId) | ||||||
| @@ -100,7 +123,7 @@ class MediaManager @Inject constructor( | |||||||
|                         Json.decodeFromString<Video>(jsonString).toLocal( |                         Json.decodeFromString<Video>(jsonString).toLocal( | ||||||
|                             context.getExternalFilesDir(null)?.path ?: "" |                             context.getExternalFilesDir(null)?.path ?: "" | ||||||
|                         ) |                         ) | ||||||
|                     } catch (e: Exception) { |                     } catch (_: Exception) { | ||||||
|                         null |                         null | ||||||
|                     } |                     } | ||||||
|                 } else { |                 } else { | ||||||
| @@ -109,12 +132,11 @@ class MediaManager @Inject constructor( | |||||||
|             } |             } | ||||||
|  |  | ||||||
|             val remoteVideos = if (remoteIds.isNotEmpty()) { |             val remoteVideos = if (remoteIds.isNotEmpty()) { | ||||||
|                 val j = ApiClient.api!!.queryVideoBulk(klass, remoteIds, token) |                 val j = apiClient.api!!.queryVideoBulk(klass, remoteIds) | ||||||
|                 j.zip(remoteIds).map { |                 j.zip(remoteIds).map { | ||||||
|                     Video( |                     Video( | ||||||
|                         klass = klass, |                         klass = klass, | ||||||
|                         id = it.second, |                         id = it.second, | ||||||
|                         token = token, |  | ||||||
|                         isLocal = false, |                         isLocal = false, | ||||||
|                         localBase = "", |                         localBase = "", | ||||||
|                         video = it.first |                         video = it.first | ||||||
| @@ -125,7 +147,7 @@ class MediaManager @Inject constructor( | |||||||
|             } |             } | ||||||
|  |  | ||||||
|             localVideos + remoteVideos |             localVideos + remoteVideos | ||||||
|         } catch (e: Exception) { |         } catch (_: Exception) { | ||||||
|             null |             null | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -133,9 +155,9 @@ class MediaManager @Inject constructor( | |||||||
|     suspend fun listComics() : List<String> |     suspend fun listComics() : List<String> | ||||||
|     { |     { | ||||||
|         try{ |         try{ | ||||||
|             val j = ApiClient.api!!.getComics(token) |             val j = apiClient.api!!.getComics() | ||||||
|             return j |             return j.sorted() | ||||||
|         }catch (e: Exception) |         }catch (_: Exception) | ||||||
|         { |         { | ||||||
|             return listOf() |             return listOf() | ||||||
|         } |         } | ||||||
| @@ -144,9 +166,9 @@ class MediaManager @Inject constructor( | |||||||
|     suspend fun queryComicInfoSingle(id: String) : Comic? |     suspend fun queryComicInfoSingle(id: String) : Comic? | ||||||
|     { |     { | ||||||
|         try{ |         try{ | ||||||
|             val j = ApiClient.api!!.queryComicInfo(id, token) |             val j = apiClient.api!!.queryComicInfo(id) | ||||||
|             return Comic(id = id, comic = j, token = token) |             return Comic(id = id, comic = j) | ||||||
|         }catch (e: Exception) |         }catch (_: Exception) | ||||||
|         { |         { | ||||||
|             return null |             return null | ||||||
|         } |         } | ||||||
| @@ -155,9 +177,9 @@ class MediaManager @Inject constructor( | |||||||
|     suspend fun queryComicInfoBulk(id: List<String>) : List<Comic>? |     suspend fun queryComicInfoBulk(id: List<String>) : List<Comic>? | ||||||
|     { |     { | ||||||
|         try{ |         try{ | ||||||
|             val j = ApiClient.api!!.queryComicInfoBulk(id, token) |             val j = apiClient.api!!.queryComicInfoBulk(id) | ||||||
|             return j.zip(id).map { Comic(id = it.second, comic = it.first, token = token) } |             return j.zip(id).map { Comic(id = it.second, comic = it.first) } | ||||||
|         }catch (e: Exception) |         }catch (_: Exception) | ||||||
|         { |         { | ||||||
|             return null |             return null | ||||||
|         } |         } | ||||||
| @@ -166,9 +188,9 @@ class MediaManager @Inject constructor( | |||||||
|     suspend fun postBookmark(id: String, bookMark: BookMark): Boolean |     suspend fun postBookmark(id: String, bookMark: BookMark): Boolean | ||||||
|     { |     { | ||||||
|         try{ |         try{ | ||||||
|             val j = ApiClient.api!!.postBookmark(id, token, bookMark) |             apiClient.api!!.postBookmark(id, bookMark) | ||||||
|             return true |             return true | ||||||
|         }catch (e: Exception) |         }catch (_: Exception) | ||||||
|         { |         { | ||||||
|             return false |             return false | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -29,9 +29,9 @@ class RecentManager @Inject constructor( | |||||||
|                 val file = File(context.filesDir, filename) |                 val file = File(context.filesDir, filename) | ||||||
|                 val content = file.readText() |                 val content = file.readText() | ||||||
|                 content |                 content | ||||||
|             } catch (e: FileNotFoundException) { |             } catch (_: FileNotFoundException) { | ||||||
|                 "[]" |                 "[]" | ||||||
|             } catch (e: IOException) { |             } catch (_: IOException) { | ||||||
|                 "[]" |                 "[]" | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -69,12 +69,12 @@ class RecentManager @Inject constructor( | |||||||
|                         if (c != null) recentComic.add(recentComic.size, c) |                         if (c != null) recentComic.add(recentComic.size, c) | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } catch (e: NoSuchMethodError) { |             } catch (_: NoSuchMethodError) { | ||||||
|                 for (id in ids) { |                 for (id in ids) { | ||||||
|                     val c = mediaManager.queryComicInfoSingle(id) |                     val c = mediaManager.queryComicInfoSingle(id) | ||||||
|                     if (c != null) recentComic.add(recentComic.size, c) |                     if (c != null) recentComic.add(recentComic.size, c) | ||||||
|                 } |                 } | ||||||
|             } catch (e: Exception) { |             } catch (_: Exception) { | ||||||
|                 for (id in ids) { |                 for (id in ids) { | ||||||
|                     val c = mediaManager.queryComicInfoSingle(id) |                     val c = mediaManager.queryComicInfoSingle(id) | ||||||
|                     if (c != null) recentComic.add(recentComic.size, c) |                     if (c != null) recentComic.add(recentComic.size, c) | ||||||
| @@ -93,9 +93,6 @@ class RecentManager @Inject constructor( | |||||||
|  |  | ||||||
|     suspend fun pushComic(context: Context, comicId: String) { |     suspend fun pushComic(context: Context, comicId: String) { | ||||||
|         mutex.withLock { |         mutex.withLock { | ||||||
|             val c = readFile(context, "recent_comic.json") |  | ||||||
|  |  | ||||||
|  |  | ||||||
|             val o = recentComic.map { it.id }.toMutableList() |             val o = recentComic.map { it.id }.toMutableList() | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -152,14 +149,11 @@ class RecentManager @Inject constructor( | |||||||
|     suspend fun pushVideo(context: Context, video: VideoQueryIndex) |     suspend fun pushVideo(context: Context, video: VideoQueryIndex) | ||||||
|     { |     { | ||||||
|         mutex.withLock{ |         mutex.withLock{ | ||||||
|             val content = readFile(context, "recent.json") |  | ||||||
|             val o = recentVideo.map{ VideoQueryIndex(it.klass, it.id) }.toMutableList() |             val o = recentVideo.map{ VideoQueryIndex(it.klass, it.id) }.toMutableList() | ||||||
|  |  | ||||||
|             if(o.contains(video)) |             if(o.contains(video)) | ||||||
|             { |             { | ||||||
|                 val index = o.indexOf(video) |                 val index = o.indexOf(video) | ||||||
|                 val temp = recentVideo[index] |  | ||||||
|  |  | ||||||
|                 recentVideo.removeAt(index) |                 recentVideo.removeAt(index) | ||||||
|             } |             } | ||||||
|             recentVideo.add(0, mediaManager.queryVideo(video.klass, video.id)!!) |             recentVideo.add(0, mediaManager.queryVideo(video.klass, video.id)!!) | ||||||
|   | |||||||
| @@ -1,325 +0,0 @@ | |||||||
| 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, |  | ||||||
|                         ) |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,295 +0,0 @@ | |||||||
| 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 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) |  | ||||||
|             ) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -0,0 +1,103 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.ExperimentalMaterial3Api | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Slider | ||||||
|  | import androidx.compose.material3.SliderColors | ||||||
|  | import androidx.compose.material3.SliderDefaults | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  |  | ||||||
|  | @OptIn(ExperimentalMaterial3Api::class) | ||||||
|  | @Composable | ||||||
|  | fun BiliStyleSlider( | ||||||
|  |     modifier: Modifier = Modifier, | ||||||
|  |     value: Float, | ||||||
|  |     onValueChange: (Float) -> Unit, | ||||||
|  |     valueRange: ClosedFloatingPointRange<Float> = 0f..1f | ||||||
|  | ) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     val trackHeight = 3.dp | ||||||
|  |  | ||||||
|  |     Slider( | ||||||
|  |         value = value, | ||||||
|  |         onValueChange = onValueChange, | ||||||
|  |         valueRange = valueRange, | ||||||
|  |         modifier = modifier, | ||||||
|  |         colors = SliderDefaults.colors( | ||||||
|  |             thumbColor = Color(0xFFFFFFFF), | ||||||
|  |             activeTrackColor = colorScheme.primary, | ||||||
|  |             inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |         ), | ||||||
|  |  | ||||||
|  |         track = { sliderPositions -> | ||||||
|  |             Box( | ||||||
|  |                 Modifier | ||||||
|  |                     .height(trackHeight) | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .background(Color.LightGray.copy(alpha = 0.3f), RoundedCornerShape(50)) | ||||||
|  |             ) { | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .align(Alignment.CenterStart) | ||||||
|  |                         .fillMaxWidth(value) | ||||||
|  |                         .fillMaxHeight() | ||||||
|  |                         .background(colorScheme.primary, RoundedCornerShape(50)) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @OptIn(ExperimentalMaterial3Api::class) | ||||||
|  | @Composable | ||||||
|  | fun BiliMiniSlider( | ||||||
|  |     modifier: Modifier = Modifier, | ||||||
|  |     value: Float, | ||||||
|  |     onValueChange: (Float) -> Unit, | ||||||
|  |     valueRange: ClosedFloatingPointRange<Float> = 0f..1f, | ||||||
|  |     colors: SliderColors = SliderDefaults.colors( | ||||||
|  |         thumbColor = Color(0xFFFFFFFF), | ||||||
|  |         activeTrackColor = MaterialTheme.colorScheme.primary, | ||||||
|  |         inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |     ) | ||||||
|  | ) { | ||||||
|  |     val trackHeight = 3.dp | ||||||
|  |  | ||||||
|  |     Slider( | ||||||
|  |         value = value, | ||||||
|  |         onValueChange = onValueChange, | ||||||
|  |         valueRange = valueRange, | ||||||
|  |         modifier = modifier, | ||||||
|  |         colors = colors, | ||||||
|  |         enabled = false, | ||||||
|  |         thumb = { | ||||||
|  |  | ||||||
|  |         }, | ||||||
|  |         track = { sliderPositions -> | ||||||
|  |             Box( | ||||||
|  |                 Modifier | ||||||
|  |                     .height(trackHeight) | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .background(colors.inactiveTrackColor, RoundedCornerShape(50)) | ||||||
|  |             ) { | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .align(Alignment.CenterStart) | ||||||
|  |                         .fillMaxWidth(value) | ||||||
|  |                         .fillMaxHeight() | ||||||
|  |                         .background(colors.activeTrackColor, RoundedCornerShape(50)) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| package com.acitelight.aether.view | package com.acitelight.aether.view.components | ||||||
| 
 | 
 | ||||||
| import androidx.compose.foundation.layout.Column | import androidx.compose.foundation.layout.Column | ||||||
| import androidx.compose.foundation.layout.fillMaxWidth | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
| @@ -0,0 +1,120 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.graphics.Brush | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.Comic | ||||||
|  | import com.acitelight.aether.view.pages.toHex | ||||||
|  | import com.acitelight.aether.viewModel.ComicScreenViewModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun ComicCard( | ||||||
|  |     comic: Comic, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     comicScreenViewModel: ComicScreenViewModel | ||||||
|  | ) { | ||||||
|  |     Card( | ||||||
|  |         colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(0.65f)), | ||||||
|  |         shape = RoundedCornerShape(8.dp), | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .wrapContentHeight(), | ||||||
|  |         onClick = { | ||||||
|  |             val route = "comic_grid_route/${comic.id.toHex()}" | ||||||
|  |             navController.navigate(route) | ||||||
|  |         } | ||||||
|  |     ) { | ||||||
|  |         Column( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |         ) { | ||||||
|  |             Box(modifier = Modifier.fillMaxSize()) { | ||||||
|  |                 AsyncImage( | ||||||
|  |                     model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                         .data(comic.getCover(comicScreenViewModel.apiClient)) | ||||||
|  |                         .memoryCacheKey("${comic.id}/cover") | ||||||
|  |                         .diskCacheKey("${comic.id}/cover") | ||||||
|  |                         .build(), | ||||||
|  |                     contentDescription = null, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .fillMaxSize() | ||||||
|  |                         .clip(RoundedCornerShape(8.dp)), | ||||||
|  |                     contentScale = ContentScale.Fit, | ||||||
|  |                     imageLoader = comicScreenViewModel.imageLoader!!, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .height(24.dp) | ||||||
|  |                         .background( | ||||||
|  |                             brush = Brush.verticalGradient( | ||||||
|  |                                 colors = listOf( | ||||||
|  |                                     Color.Transparent, | ||||||
|  |                                     Color.Black.copy(alpha = 0.45f) | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                         .align(Alignment.BottomCenter) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .align(Alignment.BottomEnd) | ||||||
|  |                             .padding(2.dp), | ||||||
|  |                         fontSize = 12.sp, | ||||||
|  |                         text = "${comic.comic.list.size} Pages", | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         color = Color.White, | ||||||
|  |                         maxLines = 1 | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             Text( | ||||||
|  |                 text = comic.comic.comic_name, | ||||||
|  |                 fontSize = 12.sp, | ||||||
|  |                 lineHeight = 14.sp, | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 maxLines = 2, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(4.dp) | ||||||
|  |                     .heightIn(min = 14.dp) | ||||||
|  |             ) | ||||||
|  |             Text( | ||||||
|  |                 text = "Id: ${comic.id}", | ||||||
|  |                 fontSize = 10.sp, | ||||||
|  |                 lineHeight = 12.sp, | ||||||
|  |                 maxLines = 1, | ||||||
|  |                 modifier = Modifier.padding(4.dp) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,55 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.PaddingValues | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.lazy.LazyRow | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.KeyImage | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun HorizontalGallery(videoPlayerViewModel: VideoPlayerViewModel) { | ||||||
|  |     val gallery by videoPlayerViewModel.currentGallery | ||||||
|  |     LazyRow( | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .height(120.dp), | ||||||
|  |         horizontalArrangement = Arrangement.spacedBy(12.dp), | ||||||
|  |         contentPadding = PaddingValues(horizontal = 24.dp) | ||||||
|  |     ) { | ||||||
|  |         items(gallery) { it -> | ||||||
|  |             SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | private fun SingleImageItem(img: KeyImage, imageLoader: ImageLoader) { | ||||||
|  |     AsyncImage( | ||||||
|  |         model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |             .data(img.url) | ||||||
|  |             .memoryCacheKey(img.key) | ||||||
|  |             .diskCacheKey(img.key) | ||||||
|  |             .build(), | ||||||
|  |         contentDescription = null, | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .clip(RoundedCornerShape(12.dp)), | ||||||
|  |         contentScale = ContentScale.Crop, | ||||||
|  |         imageLoader = imageLoader | ||||||
|  |     ) | ||||||
|  | } | ||||||
| @@ -0,0 +1,136 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardColors | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.draw.drawWithContent | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.graphics.drawOutline | ||||||
|  | import androidx.compose.ui.graphics.drawscope.Stroke | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.service.ApiClient | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun MiniPlaylistCard(modifier: Modifier, video: Video, imageLoader: ImageLoader, selected: Boolean, apiClient: ApiClient, onClick: () -> Unit) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     Card( | ||||||
|  |         modifier = modifier | ||||||
|  |             .height(80.dp) | ||||||
|  |             .fillMaxWidth(), | ||||||
|  |         colors = CardColors( | ||||||
|  |             containerColor = Color.Transparent, | ||||||
|  |             contentColor = MaterialTheme.colorScheme.onSurface, | ||||||
|  |             disabledContentColor = Color.Transparent, | ||||||
|  |             disabledContainerColor = Color.Transparent | ||||||
|  |         ), | ||||||
|  |         onClick = onClick | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         Row() | ||||||
|  |         { | ||||||
|  |             AsyncImage( | ||||||
|  |                 model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                     .data(video.getCover(apiClient)) | ||||||
|  |                     .memoryCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                     .diskCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                     .listener( | ||||||
|  |                         onStart = { }, | ||||||
|  |                         onError = { _, _ -> } | ||||||
|  |                     ) | ||||||
|  |                     .build(), | ||||||
|  |                 contentDescription = null, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .width(128.dp) | ||||||
|  |                     .fillMaxHeight() | ||||||
|  |                     .clip(RoundedCornerShape(8.dp)) | ||||||
|  |                     .then( | ||||||
|  |                         if (selected) | ||||||
|  |                             Modifier.drawWithContent { | ||||||
|  |                                 drawContent() | ||||||
|  |  | ||||||
|  |                                 val strokeWidth = 3.dp.toPx() | ||||||
|  |                                 val shape = RoundedCornerShape(8.dp) | ||||||
|  |                                 val outline = shape.createOutline(size, layoutDirection, this) | ||||||
|  |  | ||||||
|  |                                 drawOutline( | ||||||
|  |                                     outline = outline, | ||||||
|  |                                     color = colorScheme.primary, | ||||||
|  |                                     style = Stroke(width = strokeWidth) | ||||||
|  |                                 ) | ||||||
|  |                             } | ||||||
|  |                         else | ||||||
|  |                             Modifier | ||||||
|  |                     ), | ||||||
|  |                 contentScale = ContentScale.Crop, | ||||||
|  |                 imageLoader = imageLoader | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             Column( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(horizontal = 8.dp) | ||||||
|  |                     .fillMaxHeight() | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .align(Alignment.CenterVertically), | ||||||
|  |                 verticalArrangement = Arrangement.Center, | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier, | ||||||
|  |                     text = video.video.name, | ||||||
|  |                     fontSize = 13.sp, | ||||||
|  |                     maxLines = 2, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     lineHeight = 14.sp, | ||||||
|  |                     color = if(selected) colorScheme.primary else colorScheme.onSurface | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Spacer(modifier.weight(1f)) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier.height(16.dp), | ||||||
|  |                     text = video.klass, | ||||||
|  |                     fontSize = 8.sp, | ||||||
|  |                     lineHeight = 9.sp, | ||||||
|  |                     maxLines = 1, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     color = if(selected) colorScheme.primary else colorScheme.onSurface | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier.height(16.dp), | ||||||
|  |                     text = formatTime(video.video.duration), | ||||||
|  |                     fontSize = 8.sp, | ||||||
|  |                     lineHeight = 9.sp, | ||||||
|  |                     maxLines = 1, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     color = if(selected) colorScheme.primary else colorScheme.onSurface | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,108 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardColors | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.service.ApiClient | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun MiniVideoCard(modifier: Modifier, video: Video, imageLoader: ImageLoader, apiClient: ApiClient, onClick: () -> Unit) { | ||||||
|  |     Card( | ||||||
|  |         modifier = modifier | ||||||
|  |             .height(80.dp) | ||||||
|  |             .fillMaxWidth(), | ||||||
|  |         colors = CardColors( | ||||||
|  |             containerColor = Color.Transparent, | ||||||
|  |             contentColor = MaterialTheme.colorScheme.onSurface, | ||||||
|  |             disabledContentColor = Color.Transparent, | ||||||
|  |             disabledContainerColor = Color.Transparent | ||||||
|  |         ), | ||||||
|  |         onClick = onClick | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         Row() | ||||||
|  |         { | ||||||
|  |             AsyncImage( | ||||||
|  |                 model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                     .data(video.getCover(apiClient)) | ||||||
|  |                     .memoryCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                     .diskCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                     .listener( | ||||||
|  |                         onStart = { }, | ||||||
|  |                         onError = { _, _ -> } | ||||||
|  |                     ) | ||||||
|  |                     .build(), | ||||||
|  |                 contentDescription = null, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .width(128.dp) | ||||||
|  |                     .fillMaxHeight() | ||||||
|  |                     .clip(RoundedCornerShape(8.dp)), | ||||||
|  |                 contentScale = ContentScale.Crop, | ||||||
|  |                 imageLoader = imageLoader | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             Column( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(horizontal = 8.dp) | ||||||
|  |                     .fillMaxHeight() | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .align(Alignment.CenterVertically), | ||||||
|  |                 verticalArrangement = Arrangement.Center | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier, | ||||||
|  |                     text = video.video.name, | ||||||
|  |                     fontSize = 14.sp, | ||||||
|  |                     maxLines = 2, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Spacer(modifier.weight(1f)) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier.height(16.dp), | ||||||
|  |                     text = video.klass, | ||||||
|  |                     fontSize = 8.sp, | ||||||
|  |                     maxLines = 1, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier.height(16.dp), | ||||||
|  |                     text = formatTime(video.video.duration), | ||||||
|  |                     fontSize = 8.sp, | ||||||
|  |                     maxLines = 1, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,92 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.PaddingValues | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.lazy.LazyRow | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.foundation.lazy.rememberLazyListState | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.LaunchedEffect | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun PlaylistPanel(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     val name by videoPlayerViewModel.currentName | ||||||
|  |     val id by videoPlayerViewModel.currentId | ||||||
|  |  | ||||||
|  |     val listState = rememberLazyListState() | ||||||
|  |     val videos = videoPlayerViewModel.videos | ||||||
|  |  | ||||||
|  |     LaunchedEffect(id, videos) { | ||||||
|  |         val targetIndex = videos.indexOfFirst { it.id == id } | ||||||
|  |         if (targetIndex >= 0) { | ||||||
|  |             listState.scrollToItem(targetIndex) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     LazyRow( | ||||||
|  |         modifier = modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .height(80.dp), | ||||||
|  |         state = listState, | ||||||
|  |         horizontalArrangement = Arrangement.spacedBy(6.dp), | ||||||
|  |         contentPadding = PaddingValues(horizontal = 24.dp) | ||||||
|  |     ) { | ||||||
|  |         items(videos) { it -> | ||||||
|  |             Card( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxHeight() | ||||||
|  |                     .width(140.dp), | ||||||
|  |                 onClick = { | ||||||
|  |                     if (name == it.video.name) | ||||||
|  |                         return@Card | ||||||
|  |  | ||||||
|  |                     videoPlayerViewModel.viewModelScope.launch { | ||||||
|  |                         videoPlayerViewModel.startPlay(it) | ||||||
|  |                     } | ||||||
|  |                 }, | ||||||
|  |                 colors = | ||||||
|  |                     if (it.id == id) | ||||||
|  |                         CardDefaults.cardColors(containerColor = colorScheme.primary) | ||||||
|  |                     else | ||||||
|  |                         CardDefaults.cardColors() | ||||||
|  |             ) { | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .fillMaxSize() | ||||||
|  |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier.align(Alignment.Center), | ||||||
|  |                         text = it.video.name, | ||||||
|  |                         maxLines = 4, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         fontSize = 12.sp, | ||||||
|  |                         lineHeight = 13.sp | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,430 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import android.app.Activity | ||||||
|  | import android.content.Context | ||||||
|  | import android.media.AudioManager | ||||||
|  | import android.view.View | ||||||
|  | import androidx.annotation.OptIn | ||||||
|  | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | import androidx.compose.animation.fadeIn | ||||||
|  | import androidx.compose.animation.fadeOut | ||||||
|  | import androidx.compose.animation.slideInVertically | ||||||
|  | import androidx.compose.animation.slideOutVertically | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.gestures.detectDragGestures | ||||||
|  | import androidx.compose.foundation.gestures.detectTapGestures | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.size | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.automirrored.filled.VolumeUp | ||||||
|  | import androidx.compose.material.icons.filled.Brightness4 | ||||||
|  | import androidx.compose.material.icons.filled.FastForward | ||||||
|  | import androidx.compose.material.icons.filled.Fullscreen | ||||||
|  | import androidx.compose.material.icons.filled.Pause | ||||||
|  | import androidx.compose.material.icons.filled.PlayArrow | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.IconButton | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableFloatStateOf | ||||||
|  | import androidx.compose.runtime.remember | ||||||
|  | import androidx.compose.runtime.setValue | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.graphics.Brush | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.input.pointer.pointerInput | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.compose.ui.viewinterop.AndroidView | ||||||
|  | import androidx.media3.common.util.UnstableApi | ||||||
|  | import androidx.media3.exoplayer.ExoPlayer | ||||||
|  | import androidx.media3.ui.PlayerView | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  | import com.acitelight.aether.view.pages.moveBrit | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  | import kotlin.math.abs | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @OptIn(UnstableApi::class) | ||||||
|  | @Composable | ||||||
|  | fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel, cover: Float) { | ||||||
|  |     val exoPlayer: ExoPlayer = videoPlayerViewModel.player!! | ||||||
|  |     val context = LocalContext.current | ||||||
|  |     val activity = (context as? Activity)!! | ||||||
|  |  | ||||||
|  |     val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } | ||||||
|  |     val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } | ||||||
|  |     var volFactor by remember { | ||||||
|  |         mutableFloatStateOf( | ||||||
|  |             audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume.toFloat() | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun setVolume(value: Int) { | ||||||
|  |         audioManager.setStreamVolume( | ||||||
|  |             AudioManager.STREAM_MUSIC, | ||||||
|  |             value.coerceIn(0, maxVolume), | ||||||
|  |             AudioManager.FLAG_PLAY_SOUND | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Box(modifier) | ||||||
|  |     { | ||||||
|  |         AndroidView( | ||||||
|  |             factory = { | ||||||
|  |                 PlayerView( | ||||||
|  |                     it | ||||||
|  |                 ).apply { | ||||||
|  |                     player = exoPlayer | ||||||
|  |                     useController = false | ||||||
|  |                     subtitleView?.let { sv -> | ||||||
|  |                         sv.visibility = View.GONE | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .align(Alignment.TopCenter) | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |                 .pointerInput(Unit) { | ||||||
|  |                     detectDragGestures( | ||||||
|  |                         onDragStart = { offset -> | ||||||
|  |                             if (videoPlayerViewModel.locked) return@detectDragGestures | ||||||
|  |                             if (offset.x < size.width / 2) { | ||||||
|  |                                 videoPlayerViewModel.draggingPurpose = -1 | ||||||
|  |                             } else { | ||||||
|  |                                 videoPlayerViewModel.draggingPurpose = -2 | ||||||
|  |                             } | ||||||
|  |                         }, | ||||||
|  |                         onDragEnd = { | ||||||
|  |                             if (videoPlayerViewModel.isPlaying && videoPlayerViewModel.draggingPurpose == 0) | ||||||
|  |                                 exoPlayer.play() | ||||||
|  |  | ||||||
|  |                             videoPlayerViewModel.draggingPurpose = -1 | ||||||
|  |                         }, | ||||||
|  |                         onDrag = { change, dragAmount -> | ||||||
|  |                             if (videoPlayerViewModel.locked) return@detectDragGestures | ||||||
|  |                             if (abs(dragAmount.x) > abs(dragAmount.y) && | ||||||
|  |                                 (videoPlayerViewModel.draggingPurpose == -1 || videoPlayerViewModel.draggingPurpose == -2) | ||||||
|  |                             ) { | ||||||
|  |                                 videoPlayerViewModel.draggingPurpose = 0 | ||||||
|  |                                 videoPlayerViewModel.planeVisibility = true | ||||||
|  |                                 exoPlayer.pause() | ||||||
|  |                             } else if (videoPlayerViewModel.draggingPurpose == -1) videoPlayerViewModel.draggingPurpose = | ||||||
|  |                                 1 | ||||||
|  |                             else if (videoPlayerViewModel.draggingPurpose == -2) videoPlayerViewModel.draggingPurpose = | ||||||
|  |                                 2 | ||||||
|  |  | ||||||
|  |                             if (videoPlayerViewModel.draggingPurpose == 0) { | ||||||
|  |                                 exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong()) | ||||||
|  |                                 videoPlayerViewModel.playProcess = | ||||||
|  |                                     exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat() | ||||||
|  |                             } else if (videoPlayerViewModel.draggingPurpose == 2) { | ||||||
|  |                                 val cu = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) | ||||||
|  |                                 volFactor = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) | ||||||
|  |                                     .toFloat() / maxVolume.toFloat() | ||||||
|  |                                 if (dragAmount.y < 0) | ||||||
|  |                                     setVolume(cu + 1) | ||||||
|  |                                 else if (dragAmount.y > 0) | ||||||
|  |                                     setVolume(cu - 1) | ||||||
|  |                             } else if (videoPlayerViewModel.draggingPurpose == 1) { | ||||||
|  |                                 moveBrit(dragAmount.y, activity, videoPlayerViewModel) | ||||||
|  |                             } | ||||||
|  |  | ||||||
|  |                         } | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |                 .pointerInput(Unit) { | ||||||
|  |                     detectTapGestures( | ||||||
|  |                         onDoubleTap = { | ||||||
|  |                             if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |                             videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying | ||||||
|  |                             if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() | ||||||
|  |                         }, | ||||||
|  |                         onTap = { | ||||||
|  |                             if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |                             videoPlayerViewModel.planeVisibility = | ||||||
|  |                                 !videoPlayerViewModel.planeVisibility | ||||||
|  |                         }, | ||||||
|  |                         onLongPress = { | ||||||
|  |                             if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |                             videoPlayerViewModel.isLongPressing = true | ||||||
|  |                             exoPlayer.playbackParameters = exoPlayer.playbackParameters | ||||||
|  |                                 .withSpeed(3.0f) | ||||||
|  |                         }, | ||||||
|  |                         onPress = { offset -> | ||||||
|  |                             val pressResult = tryAwaitRelease() | ||||||
|  |                             if (pressResult && videoPlayerViewModel.isLongPressing) { | ||||||
|  |                                 videoPlayerViewModel.isLongPressing = false | ||||||
|  |                                 exoPlayer.playbackParameters = exoPlayer.playbackParameters | ||||||
|  |                                     .withSpeed(1.0f) | ||||||
|  |                             } | ||||||
|  |                         }, | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = videoPlayerViewModel.isLongPressing, | ||||||
|  |             enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |             exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .align(Alignment.TopCenter) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Box( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.TopCenter) | ||||||
|  |                     .padding(top = 24.dp) | ||||||
|  |                     .background(Color(0x44000000), RoundedCornerShape(18)) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Row { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = Icons.Filled.FastForward, | ||||||
|  |                         contentDescription = "Fast Forward", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .size(36.dp) | ||||||
|  |                             .padding(4.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     Text( | ||||||
|  |                         text = "3X Speed...", | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(4.dp) | ||||||
|  |                             .align(Alignment.CenterVertically), | ||||||
|  |                         fontSize = 16.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         color = Color(0xFFFFFFFF) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = videoPlayerViewModel.draggingPurpose == 0, | ||||||
|  |             enter = fadeIn( | ||||||
|  |                 initialAlpha = 0f, | ||||||
|  |             ), | ||||||
|  |             exit = fadeOut( | ||||||
|  |                 targetAlpha = 0f | ||||||
|  |             ), | ||||||
|  |             modifier = Modifier.align(Alignment.Center) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Text( | ||||||
|  |                 text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${ | ||||||
|  |                     formatTime( | ||||||
|  |                         (exoPlayer.duration) | ||||||
|  |                     ) | ||||||
|  |                 }", | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 modifier = Modifier.padding(bottom = 12.dp), | ||||||
|  |                 fontSize = 18.sp | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = videoPlayerViewModel.draggingPurpose == 2, | ||||||
|  |             enter = fadeIn( | ||||||
|  |                 initialAlpha = 0f, | ||||||
|  |             ), | ||||||
|  |             exit = fadeOut( | ||||||
|  |                 targetAlpha = 0f | ||||||
|  |             ), | ||||||
|  |             modifier = Modifier.align(Alignment.Center) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Row(Modifier | ||||||
|  |                 .background(Color(0x88000000), RoundedCornerShape(18)) | ||||||
|  |                 .width(200.dp)) | ||||||
|  |             { | ||||||
|  |                 Icon( | ||||||
|  |                     imageVector = Icons.AutoMirrored.Filled.VolumeUp, | ||||||
|  |                     contentDescription = "Vol", | ||||||
|  |                     tint = Color.White, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .size(48.dp) | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) | ||||||
|  |                 BiliMiniSlider( | ||||||
|  |                     value = volFactor, | ||||||
|  |                     onValueChange = {}, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .height(4.dp) | ||||||
|  |                         .padding(horizontal = 8.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = videoPlayerViewModel.draggingPurpose == 1, | ||||||
|  |             enter = fadeIn( | ||||||
|  |                 initialAlpha = 0f, | ||||||
|  |             ), | ||||||
|  |             exit = fadeOut( | ||||||
|  |                 targetAlpha = 0f | ||||||
|  |             ), | ||||||
|  |             modifier = Modifier.align(Alignment.Center) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Row(Modifier | ||||||
|  |                 .background(Color(0x88000000), RoundedCornerShape(18)) | ||||||
|  |                 .width(200.dp)) | ||||||
|  |             { | ||||||
|  |                 Icon( | ||||||
|  |                     imageVector = Icons.Default.Brightness4, | ||||||
|  |                     contentDescription = "Brightness", | ||||||
|  |                     tint = Color.White, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .size(48.dp) | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) | ||||||
|  |                 BiliMiniSlider( | ||||||
|  |                     value = videoPlayerViewModel.brit, | ||||||
|  |                     onValueChange = {}, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .height(4.dp) | ||||||
|  |                         .padding(horizontal = 8.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (cover > 0.0f) | ||||||
|  |             Spacer(Modifier | ||||||
|  |                 .background(MaterialTheme.colorScheme.primary.copy(cover)) | ||||||
|  |                 .fillMaxSize()) | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = (!videoPlayerViewModel.planeVisibility) || videoPlayerViewModel.locked, | ||||||
|  |             enter = fadeIn( | ||||||
|  |                 initialAlpha = 0f, | ||||||
|  |             ), | ||||||
|  |             exit = fadeOut( | ||||||
|  |                 targetAlpha = 0f | ||||||
|  |             ), | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |                 .align(Alignment.BottomCenter) | ||||||
|  |         ) { | ||||||
|  |             BiliMiniSlider( | ||||||
|  |                 value = videoPlayerViewModel.playProcess, | ||||||
|  |                 onValueChange = {}, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .height(4.dp) | ||||||
|  |                     .align(Alignment.BottomCenter) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = videoPlayerViewModel.planeVisibility && (!videoPlayerViewModel.locked), | ||||||
|  |             enter = fadeIn( | ||||||
|  |                 initialAlpha = 0f, | ||||||
|  |             ), | ||||||
|  |             exit = fadeOut( | ||||||
|  |                 targetAlpha = 0f | ||||||
|  |             ), | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |                 .align(Alignment.BottomCenter) | ||||||
|  |                 .height(42.dp) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Row( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .align(Alignment.BottomCenter) | ||||||
|  |                     .background( | ||||||
|  |                         brush = Brush.verticalGradient( | ||||||
|  |                             colors = listOf( | ||||||
|  |                                 Color.Transparent, | ||||||
|  |                                 Color.Black.copy(alpha = 0.4f), | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                 horizontalArrangement = Arrangement.SpaceBetween, | ||||||
|  |             ) { | ||||||
|  |                 IconButton( | ||||||
|  |                     onClick = { | ||||||
|  |                         videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying | ||||||
|  |                         if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() | ||||||
|  |                     }, | ||||||
|  |                     Modifier | ||||||
|  |                         .size(36.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, | ||||||
|  |                         contentDescription = "Play/Pause", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier.size(32.dp) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 BiliStyleSlider( | ||||||
|  |                     value = videoPlayerViewModel.playProcess, | ||||||
|  |                     onValueChange = { value -> | ||||||
|  |                         exoPlayer.seekTo((exoPlayer.duration * value).toLong()) | ||||||
|  |                     }, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .height(8.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                         .weight(1f) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     text = formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong()), | ||||||
|  |                     maxLines = 1, | ||||||
|  |                     fontSize = 12.sp, | ||||||
|  |                     color = Color(0xFFFFFFFF), | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .width(80.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                         .padding(start = 12.dp) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 IconButton( | ||||||
|  |                     onClick = { | ||||||
|  |                         videoPlayerViewModel.isLandscape = true | ||||||
|  |                     }, | ||||||
|  |                     Modifier | ||||||
|  |                         .size(36.dp) | ||||||
|  |                         .align(Alignment.CenterVertically) | ||||||
|  |                 ) { | ||||||
|  |                     Icon( | ||||||
|  |                         Icons.Default.Fullscreen, | ||||||
|  |                         contentDescription = "FullScreen", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier.size(32.dp) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         SubtitleOverlay( | ||||||
|  |             cues = videoPlayerViewModel.cues, | ||||||
|  |             modifier = Modifier.matchParentSize() | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
| @@ -0,0 +1,84 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import android.text.Layout | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.wrapContentWidth | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.geometry.Offset | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.graphics.Shadow | ||||||
|  | import androidx.compose.ui.platform.LocalDensity | ||||||
|  | import androidx.compose.ui.text.TextStyle | ||||||
|  | import androidx.compose.ui.text.style.TextAlign | ||||||
|  | import androidx.compose.ui.text.style.TextOverflow | ||||||
|  | import androidx.compose.ui.unit.Dp | ||||||
|  | import androidx.compose.ui.unit.TextUnit | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.media3.common.text.Cue | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun SubtitleOverlay( | ||||||
|  |     cues: List<Cue>, | ||||||
|  |     modifier: Modifier = Modifier, | ||||||
|  |     maxLines: Int = 2, | ||||||
|  |     textSize: TextUnit = 14.sp, | ||||||
|  |     backgroundAlpha: Float = 0.6f, | ||||||
|  |     horizontalMargin: Dp = 16.dp, | ||||||
|  |     bottomMargin: Dp = 14.dp, | ||||||
|  |     contentPadding: Dp = 6.dp, | ||||||
|  |     cornerRadius: Dp = 6.dp, | ||||||
|  |     textColor: Color = Color.White | ||||||
|  | ) { | ||||||
|  |     val raw = if (cues.isEmpty()) "" else cues.joinToString(separator = "\n") { | ||||||
|  |         it.text?.toString() ?: "" | ||||||
|  |     }.trim() | ||||||
|  |     if (raw.isEmpty()) return | ||||||
|  |  | ||||||
|  |     val textAlign = when (cues.firstOrNull()?.textAlignment) { | ||||||
|  |         Layout.Alignment.ALIGN_CENTER -> TextAlign.Center | ||||||
|  |         Layout.Alignment.ALIGN_OPPOSITE -> TextAlign.End | ||||||
|  |         Layout.Alignment.ALIGN_NORMAL -> TextAlign.Start | ||||||
|  |         else -> TextAlign.Center | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val blurPx = with(LocalDensity.current) { (2.dp).toPx() } | ||||||
|  |  | ||||||
|  |     Box( | ||||||
|  |         modifier = modifier, | ||||||
|  |         contentAlignment = Alignment.BottomCenter | ||||||
|  |     ) { | ||||||
|  |         Box( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .padding(start = horizontalMargin, end = horizontalMargin, bottom = bottomMargin) | ||||||
|  |                 .wrapContentWidth(Alignment.CenterHorizontally) | ||||||
|  |                 .clip(RoundedCornerShape(cornerRadius)) | ||||||
|  |                 .background(Color.Black.copy(alpha = backgroundAlpha)) | ||||||
|  |                 .padding(horizontal = 12.dp, vertical = contentPadding) | ||||||
|  |         ) { | ||||||
|  |             Text( | ||||||
|  |                 text = raw, | ||||||
|  |                 maxLines = maxLines, | ||||||
|  |                 overflow = TextOverflow.Ellipsis, | ||||||
|  |                 style = TextStyle( | ||||||
|  |                     color = textColor, | ||||||
|  |                     fontSize = textSize, | ||||||
|  |                     shadow = Shadow( | ||||||
|  |                         color = Color.Black.copy(alpha = 0.85f), | ||||||
|  |                         offset = Offset(0f, 0f), | ||||||
|  |                         blurRadius = blurPx | ||||||
|  |                     ) | ||||||
|  |                 ), | ||||||
|  |                 textAlign = textAlign, | ||||||
|  |                 modifier = Modifier | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,186 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import android.widget.Toast | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.combinedClickable | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.widthIn | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableStateListOf | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.graphics.Brush | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.Global.updateRelate | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  | import com.acitelight.aether.view.pages.toHex | ||||||
|  | import com.acitelight.aether.viewModel.VideoScreenViewModel | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VideoCard( | ||||||
|  |     videos: List<Video>, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     videoScreenViewModel: VideoScreenViewModel | ||||||
|  | ) { | ||||||
|  |     val tabIndex by videoScreenViewModel.tabIndex; | ||||||
|  |     val video = videos.first() | ||||||
|  |     Card( | ||||||
|  |         colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(0.65f)), | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .wrapContentHeight() | ||||||
|  |             .combinedClickable( | ||||||
|  |                 onClick = { | ||||||
|  |                     updateRelate( | ||||||
|  |                         videoScreenViewModel.videoLibrary.classesMap[videoScreenViewModel.videoLibrary.classes[tabIndex]] | ||||||
|  |                             ?: mutableStateListOf(), video | ||||||
|  |                     ) | ||||||
|  |                     val vg = videos.joinToString(",") { "${it.klass}/${it.id}" }.toHex() | ||||||
|  |                     val route = "video_player_route/$vg" | ||||||
|  |                     navController.navigate(route) | ||||||
|  |                 }, | ||||||
|  |                 onLongClick = { | ||||||
|  |                     videoScreenViewModel.viewModelScope.launch { | ||||||
|  |                         for(i in videos) | ||||||
|  |                         { | ||||||
|  |                             videoScreenViewModel.download(i) | ||||||
|  |                         } | ||||||
|  |                         Toast.makeText( | ||||||
|  |                             videoScreenViewModel.context, | ||||||
|  |                             "Start downloading ${video.video.group}", | ||||||
|  |                             Toast.LENGTH_SHORT | ||||||
|  |                         ).show() | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |         shape = RoundedCornerShape(6.dp), | ||||||
|  |     ) { | ||||||
|  |         Column( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .fillMaxWidth(), | ||||||
|  |         ) { | ||||||
|  |             Box(modifier = Modifier.fillMaxSize()) { | ||||||
|  |  | ||||||
|  |                 AsyncImage( | ||||||
|  |                     model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                         .data(video.getCover(videoScreenViewModel.apiClient)) | ||||||
|  |                         .memoryCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                         .diskCacheKey("${video.klass}/${video.id}/cover") | ||||||
|  |                         .build(), | ||||||
|  |                     contentDescription = null, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .fillMaxSize() | ||||||
|  |                         .clip(RoundedCornerShape(8.dp)), | ||||||
|  |                     contentScale = ContentScale.Fit, | ||||||
|  |                     imageLoader = videoScreenViewModel.imageLoader!! | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  |                 Box( | ||||||
|  |                     Modifier | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .height(24.dp) | ||||||
|  |                         .background( | ||||||
|  |                             brush = Brush.verticalGradient( | ||||||
|  |                                 colors = listOf( | ||||||
|  |                                     Color.Transparent, | ||||||
|  |                                     Color.Black.copy(alpha = 0.6f) | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                         .align(Alignment.BottomCenter) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.BottomStart) | ||||||
|  |                         .padding(horizontal =  2.dp), | ||||||
|  |                     text = "${videos.size} Videos", | ||||||
|  |                     fontSize = 12.sp, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     lineHeight = 13.sp, | ||||||
|  |                     color = Color.White | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.BottomEnd) | ||||||
|  |                         .padding(horizontal =  2.dp), | ||||||
|  |                     text = formatTime(video.video.duration), | ||||||
|  |                     fontSize = 12.sp, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     lineHeight = 13.sp, | ||||||
|  |                     color = Color.White | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 if (videos.all{ it.isLocal }) | ||||||
|  |                     Card( | ||||||
|  |                         Modifier | ||||||
|  |                             .align(Alignment.TopStart) | ||||||
|  |                             .padding(5.dp) | ||||||
|  |                             .widthIn(max = 46.dp) | ||||||
|  |                     ) { | ||||||
|  |                         Box(Modifier.fillMaxWidth()) | ||||||
|  |                         { | ||||||
|  |                             Text( | ||||||
|  |                                 modifier = Modifier.align(Alignment.Center), | ||||||
|  |                                 text = "Local", | ||||||
|  |                                 fontSize = 14.sp, | ||||||
|  |                                 fontWeight = FontWeight.Bold | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |             } | ||||||
|  |             Text( | ||||||
|  |                 text = video.video.group ?: video.video.name, | ||||||
|  |                 fontSize = 12.sp, | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 maxLines = 2, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(4.dp) | ||||||
|  |                     .background(Color.Transparent) | ||||||
|  |                     .heightIn(min = 24.dp), | ||||||
|  |                 lineHeight = 14.sp | ||||||
|  |             ) | ||||||
|  |             Spacer(modifier = Modifier.weight(1f)) | ||||||
|  |             Box( | ||||||
|  |                 modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth() | ||||||
|  |             ) { | ||||||
|  |                 Text(modifier = Modifier.align(Alignment.CenterStart), text = "Class: ${video.klass}", fontSize = 10.sp, maxLines = 1) | ||||||
|  |                 Text(modifier = Modifier.align(Alignment.CenterEnd), text = "Id: ${ | ||||||
|  |                     videos.take(5).joinToString( | ||||||
|  |                         "," | ||||||
|  |                     ) { it.id } | ||||||
|  |                 }", fontSize = 10.sp, maxLines = 1) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| package com.acitelight.aether.view | package com.acitelight.aether.view.components | ||||||
| 
 | 
 | ||||||
| import androidx.compose.foundation.background | import androidx.compose.foundation.background | ||||||
| import androidx.compose.foundation.clickable | import androidx.compose.foundation.clickable | ||||||
| @@ -6,34 +6,24 @@ import androidx.compose.foundation.layout.Arrangement | |||||||
| import androidx.compose.foundation.layout.Box | import androidx.compose.foundation.layout.Box | ||||||
| import androidx.compose.foundation.layout.Column | import androidx.compose.foundation.layout.Column | ||||||
| import androidx.compose.foundation.layout.Row | import androidx.compose.foundation.layout.Row | ||||||
| import androidx.compose.foundation.layout.fillMaxSize |  | ||||||
| import androidx.compose.foundation.layout.fillMaxWidth | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
| import androidx.compose.foundation.layout.heightIn | import androidx.compose.foundation.layout.height | ||||||
| import androidx.compose.foundation.layout.padding | 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.foundation.shape.RoundedCornerShape | ||||||
| import androidx.compose.material.icons.Icons | 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.Delete | ||||||
|  | import androidx.compose.material.icons.filled.Pause | ||||||
| import androidx.compose.material.icons.filled.PlayArrow | import androidx.compose.material.icons.filled.PlayArrow | ||||||
|  | import androidx.compose.material.icons.filled.Stop | ||||||
| import androidx.compose.material3.Button | import androidx.compose.material3.Button | ||||||
| import androidx.compose.material3.Card | import androidx.compose.material3.Card | ||||||
| import androidx.compose.material3.CardDefaults | 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.Icon | ||||||
| import androidx.compose.material3.LinearProgressIndicator | import androidx.compose.material3.LinearProgressIndicator | ||||||
| import androidx.compose.material3.MaterialTheme | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.ProgressIndicatorDefaults | import androidx.compose.material3.ProgressIndicatorDefaults | ||||||
| import androidx.compose.material3.Surface |  | ||||||
| import androidx.compose.material3.Text | import androidx.compose.material3.Text | ||||||
| import androidx.compose.runtime.Composable | 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.Alignment | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| import androidx.compose.ui.graphics.Color | import androidx.compose.ui.graphics.Color | ||||||
| @@ -42,57 +32,33 @@ import androidx.compose.ui.platform.LocalContext | |||||||
| import androidx.compose.ui.text.font.FontWeight | import androidx.compose.ui.text.font.FontWeight | ||||||
| import androidx.compose.ui.unit.dp | import androidx.compose.ui.unit.dp | ||||||
| import androidx.compose.ui.unit.sp | import androidx.compose.ui.unit.sp | ||||||
| import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel |  | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import androidx.navigation.NavHostController | import androidx.navigation.NavHostController | ||||||
| import androidx.navigation.Navigator |  | ||||||
| import coil3.compose.AsyncImage | import coil3.compose.AsyncImage | ||||||
| import coil3.request.ImageRequest | import coil3.request.ImageRequest | ||||||
| import com.acitelight.aether.Global.updateRelate | import com.acitelight.aether.Global.updateRelate | ||||||
| import com.acitelight.aether.model.DownloadItemState |  | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.model.VideoDownloadItemState | ||||||
|  | import com.acitelight.aether.view.pages.toHex | ||||||
| import com.acitelight.aether.viewModel.TransmissionScreenViewModel | import com.acitelight.aether.viewModel.TransmissionScreenViewModel | ||||||
| import com.tonyodev.fetch2.Download |  | ||||||
| import com.tonyodev.fetch2.FetchListener |  | ||||||
| import com.tonyodev.fetch2.Status | import com.tonyodev.fetch2.Status | ||||||
| import com.tonyodev.fetch2core.DownloadBlock |  | ||||||
| import kotlinx.coroutines.Dispatchers | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
| import kotlinx.coroutines.withContext | import kotlinx.coroutines.withContext | ||||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||||
| import java.io.File | import java.io.File | ||||||
|  | import kotlin.math.abs | ||||||
| 
 | 
 | ||||||
| @Composable | @Composable | ||||||
| fun TransmissionScreen(navigator: NavHostController, transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel<TransmissionScreenViewModel>()) { | fun VideoDownloadCard( | ||||||
|     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, |     navigator: NavHostController, | ||||||
|     viewModel: TransmissionScreenViewModel, |     viewModel: TransmissionScreenViewModel, | ||||||
|     model: DownloadItemState, |     model: VideoDownloadItemState, | ||||||
|     onPause: () -> Unit, |     onPause: () -> Unit, | ||||||
|     onResume: () -> Unit, |     onResume: () -> Unit, | ||||||
|     onCancel: () -> Unit, |     onCancel: () -> Unit, | ||||||
|     onDelete: () -> Unit |     onDelete: () -> Unit, | ||||||
|  |     onRetry: () -> Unit | ||||||
| ) { | ) { | ||||||
|     Card( |     Card( | ||||||
|         shape = RoundedCornerShape(8.dp), |         shape = RoundedCornerShape(8.dp), | ||||||
| @@ -102,24 +68,53 @@ private fun DownloadCard( | |||||||
|             .padding(8.dp) |             .padding(8.dp) | ||||||
|             .background(Color.Transparent) |             .background(Color.Transparent) | ||||||
|             .clickable(onClick = { |             .clickable(onClick = { | ||||||
|                 if(model.status == Status.COMPLETED) |                 if (model.status == Status.COMPLETED) { | ||||||
|                 { |  | ||||||
|                     viewModel.viewModelScope.launch(Dispatchers.IO) |                     viewModel.viewModelScope.launch(Dispatchers.IO) | ||||||
|                     { |                     { | ||||||
|                         val downloaded = viewModel.fetchManager.getAllDownloadsAsync().filter { |                         val downloaded = viewModel.fetchManager.getAllDownloadsAsync().filter { | ||||||
|                             it.status == Status.COMPLETED && it.extras.getString("isComic", "") != "true" |                             it.status == Status.COMPLETED && it.extras.getString( | ||||||
|  |                                 "class", | ||||||
|  |                                 "" | ||||||
|  |                             ) != "comic" && it.extras.getString( | ||||||
|  |                                 "type", | ||||||
|  |                                 "" | ||||||
|  |                             ) == "main" | ||||||
|                         } |                         } | ||||||
| 
 | 
 | ||||||
|                         val jsonQuery = downloaded.map{ File( |                         val jsonQuery = downloaded.map { | ||||||
|  |                             File( | ||||||
|                                 viewModel.context.getExternalFilesDir(null), |                                 viewModel.context.getExternalFilesDir(null), | ||||||
|                             "videos/${it.extras.getString("class", "")}/${it.extras.getString("id", "")}/summary.json").readText() } |                                 "videos/${ | ||||||
|                             .map {  Json.decodeFromString<Video>(it).toLocal(viewModel.context.getExternalFilesDir(null)!!.path) } |                                     it.extras.getString( | ||||||
|  |                                         "class", | ||||||
|  |                                         "" | ||||||
|  |                                     ) | ||||||
|  |                                 }/${it.extras.getString("id", "")}/summary.json" | ||||||
|  |                             ).readText() | ||||||
|  |                         } | ||||||
|  |                             .map { | ||||||
|  |                                 Json.decodeFromString<Video>(it) | ||||||
|  |                                     .toLocal(viewModel.context.getExternalFilesDir(null)!!.path) | ||||||
|  |                             } | ||||||
| 
 | 
 | ||||||
|                         updateRelate( |                         updateRelate( | ||||||
|                             jsonQuery, jsonQuery.first { it.id == model.vid && it.klass == model.klass } |                             jsonQuery, | ||||||
|  |                             jsonQuery.first { it.id == model.vid && it.klass == model.klass } | ||||||
|                         ) |                         ) | ||||||
|                         val route = "video_player_route/${"${model.klass}/${model.vid}".toHex()}" | 
 | ||||||
|                         withContext(Dispatchers.Main){ |                         val playList = mutableListOf<String>() | ||||||
|  |                         val fv = viewModel.videoLibrary.classesMap.map { it.value }.flatten() | ||||||
|  |                         val video = fv.firstOrNull { it.klass == model.klass && it.id == model.vid } | ||||||
|  | 
 | ||||||
|  |                         if (video != null) { | ||||||
|  |                             val group = fv.filter { it.klass == video.klass && it.video.group == video.video.group } | ||||||
|  |                             for (i in group.sortedWith(compareBy(naturalOrder()) { it.video.name })) { | ||||||
|  |                                 playList.add("${i.klass}/${i.id}") | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  | 
 | ||||||
|  |                         val route = "video_player_route/${(playList.joinToString(",") + "|${model.vid}").toHex()}" | ||||||
|  |                         withContext(Dispatchers.Main) { | ||||||
|                             navigator.navigate(route) |                             navigator.navigate(route) | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
| @@ -136,19 +131,25 @@ private fun DownloadCard( | |||||||
|                 modifier = Modifier.fillMaxWidth() |                 modifier = Modifier.fillMaxWidth() | ||||||
|             ) { |             ) { | ||||||
|                 Column(modifier = Modifier.weight(1f)) { |                 Column(modifier = Modifier.weight(1f)) { | ||||||
|                     Text(text = model.fileName, style = MaterialTheme.typography.titleMedium) |                     Text(text = model.fileName, style = MaterialTheme.typography.titleMedium, maxLines = 2) | ||||||
|  |                     // Text(text = model.filePath, style = MaterialTheme.typography.titleSmall) | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             Box(Modifier |             Box( | ||||||
|  |                 Modifier | ||||||
|                     .fillMaxWidth() |                     .fillMaxWidth() | ||||||
|                 .padding(top = 5.dp)) |                     .padding(top = 5.dp) | ||||||
|  |             ) | ||||||
|             { |             { | ||||||
|                 Card( |                 Card( | ||||||
|                     shape = RoundedCornerShape(8.dp), |                     shape = RoundedCornerShape(8.dp), | ||||||
|                     modifier = Modifier.align(Alignment.CenterStart) |                     modifier = Modifier.align(Alignment.CenterStart) | ||||||
|                 ) { |                 ) { | ||||||
|  |                     val video = viewModel.modelToVideo(model) | ||||||
|  | 
 | ||||||
|  |                     if (video == null) | ||||||
|                         AsyncImage( |                         AsyncImage( | ||||||
|                             model = ImageRequest.Builder(LocalContext.current) |                             model = ImageRequest.Builder(LocalContext.current) | ||||||
|                                 .data( |                                 .data( | ||||||
| @@ -157,17 +158,31 @@ private fun DownloadCard( | |||||||
|                                         "videos/${model.klass}/${model.vid}/cover.jpg" |                                         "videos/${model.klass}/${model.vid}/cover.jpg" | ||||||
|                                     ) |                                     ) | ||||||
|                                 ) |                                 ) | ||||||
|  |                                 .memoryCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|                                 .diskCacheKey("${model.klass}/${model.vid}/cover") |                                 .diskCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|                                 .build(), |                                 .build(), | ||||||
|                             contentDescription = null, |                             contentDescription = null, | ||||||
|                         modifier = Modifier.heightIn(max = 100.dp), |                             modifier = Modifier.height(100.dp), | ||||||
|                             contentScale = ContentScale.Fit |                             contentScale = ContentScale.Fit | ||||||
|                         ) |                         ) | ||||||
|  |                     else { | ||||||
|  |                         AsyncImage( | ||||||
|  |                             model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                                 .data(video.getCover(viewModel.apiClient)) | ||||||
|  |                                 .memoryCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                                 .diskCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                                 .build(), | ||||||
|  |                             contentDescription = null, | ||||||
|  |                             modifier = Modifier.height(100.dp), | ||||||
|  |                             contentScale = ContentScale.Fit, | ||||||
|  |                             imageLoader = viewModel.imageLoader!! | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|                 } |                 } | ||||||
| 
 | 
 | ||||||
|                 Column(Modifier.align(Alignment.BottomEnd)) { |                 Column(Modifier.align(Alignment.BottomEnd)) { | ||||||
|                     Text( |                     Text( | ||||||
|                         text = "${model.progress}%", |                         text = "${model.progress.coerceIn(0, 100)}%", | ||||||
|                         modifier = Modifier |                         modifier = Modifier | ||||||
|                             .padding(start = 8.dp) |                             .padding(start = 8.dp) | ||||||
|                             .align(Alignment.End) |                             .align(Alignment.End) | ||||||
| @@ -191,7 +206,7 @@ private fun DownloadCard( | |||||||
| 
 | 
 | ||||||
|             // progress bar |             // progress bar | ||||||
|             LinearProgressIndicator( |             LinearProgressIndicator( | ||||||
|                 progress = { model.progress.coerceIn(0, 100) / 100f }, |                 progress = { abs(model.progress).coerceIn(0, 100) / 100f }, | ||||||
|                 modifier = Modifier |                 modifier = Modifier | ||||||
|                     .fillMaxWidth() |                     .fillMaxWidth() | ||||||
|                     .padding(top = 8.dp, bottom = 8.dp), |                     .padding(top = 8.dp, bottom = 8.dp), | ||||||
| @@ -241,7 +256,7 @@ private fun DownloadCard( | |||||||
| 
 | 
 | ||||||
|                     else -> { |                     else -> { | ||||||
|                         // for FAILED, CANCELLED, REMOVED etc. |                         // for FAILED, CANCELLED, REMOVED etc. | ||||||
|                         Button(onClick = onResume) { |                         Button(onClick = onRetry) { | ||||||
|                             Icon( |                             Icon( | ||||||
|                                 imageVector = Icons.Default.PlayArrow, |                                 imageVector = Icons.Default.PlayArrow, | ||||||
|                                 contentDescription = "Retry" |                                 contentDescription = "Retry" | ||||||
| @@ -0,0 +1,229 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.clickable | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.size | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.layout.widthIn | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.filled.Delete | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.IconButton | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.SliderDefaults | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.VideoDownloadItemState | ||||||
|  | import com.acitelight.aether.viewModel.TransmissionScreenViewModel | ||||||
|  | import com.tonyodev.fetch2.Status | ||||||
|  | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import java.io.File | ||||||
|  | import kotlin.math.abs | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VideoDownloadCardMini( | ||||||
|  |     navigator: NavHostController, | ||||||
|  |     viewModel: TransmissionScreenViewModel, | ||||||
|  |     model: VideoDownloadItemState, | ||||||
|  |     onPause: () -> Unit, | ||||||
|  |     onResume: () -> Unit, | ||||||
|  |     onCancel: () -> Unit, | ||||||
|  |     onDelete: () -> Unit, | ||||||
|  |     onRetry: () -> Unit | ||||||
|  | ) { | ||||||
|  |     val video = viewModel.modelToVideo(model) | ||||||
|  |     val imageModel = | ||||||
|  |         if (video == null) | ||||||
|  |             ImageRequest.Builder(LocalContext.current) | ||||||
|  |                 .data( | ||||||
|  |                     File( | ||||||
|  |                         viewModel.context.getExternalFilesDir(null), | ||||||
|  |                         "videos/${model.klass}/${model.vid}/cover.jpg" | ||||||
|  |                     ) | ||||||
|  |                 ) | ||||||
|  |                 .memoryCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                 .diskCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                 .build() | ||||||
|  |         else | ||||||
|  |             ImageRequest.Builder(LocalContext.current) | ||||||
|  |                 .data(video.getCover(viewModel.apiClient)) | ||||||
|  |                 .memoryCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                 .diskCacheKey("${model.klass}/${model.vid}/cover") | ||||||
|  |                 .build() | ||||||
|  |  | ||||||
|  |     Card( | ||||||
|  |         colors = CardDefaults.cardColors(containerColor = Color.Transparent), | ||||||
|  |         shape = RoundedCornerShape(8.dp), | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .padding(horizontal = 4.dp) | ||||||
|  |             .background(Color.Transparent) | ||||||
|  |             .clickable(onClick = { | ||||||
|  |                 when (model.status) { | ||||||
|  |                     Status.COMPLETED -> viewModel.viewModelScope.launch(Dispatchers.IO) | ||||||
|  |                     { | ||||||
|  |                         viewModel.playStart(model, navigator) | ||||||
|  |                     } | ||||||
|  |                     Status.DOWNLOADING -> onPause() | ||||||
|  |                     Status.PAUSED -> onResume() | ||||||
|  |                     Status.ADDED, Status.FAILED, Status.CANCELLED -> onRetry() | ||||||
|  |                     else -> {} | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |             .height(100.dp) | ||||||
|  |     ) { | ||||||
|  |         Row( | ||||||
|  |             modifier = Modifier.fillMaxSize() | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Box(Modifier | ||||||
|  |                 .fillMaxHeight()) | ||||||
|  |             { | ||||||
|  |                 AsyncImage( | ||||||
|  |                     model = imageModel, | ||||||
|  |                     contentDescription = null, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .height(100.dp) | ||||||
|  |                         .clip(RoundedCornerShape(8.dp)) | ||||||
|  |                         .widthIn(max = 150.dp) | ||||||
|  |                         .background(Color.Black), | ||||||
|  |                     contentScale = ContentScale.Crop | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 IconButton( | ||||||
|  |                     onClick = onDelete, | ||||||
|  |                     Modifier | ||||||
|  |                         .padding(2.dp) | ||||||
|  |                         .size(24.dp) | ||||||
|  |                         .align(Alignment.TopStart) | ||||||
|  |                         .background(MaterialTheme.colorScheme.error, RoundedCornerShape(4.dp)) | ||||||
|  |                         .clip(RoundedCornerShape(4.dp)) | ||||||
|  |                 ) { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = Icons.Default.Delete, | ||||||
|  |                         contentDescription = "Delete", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier.size(20.dp) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             Box( | ||||||
|  |                 Modifier | ||||||
|  |                     .fillMaxSize() | ||||||
|  |                     .padding(all = 4.dp) | ||||||
|  |                     .padding(end = 4.dp) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Text( | ||||||
|  |                     text = model.fileName, | ||||||
|  |                     lineHeight = 14.sp, | ||||||
|  |                     fontSize = 12.sp, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     maxLines = 2, | ||||||
|  |                     modifier = Modifier.align(Alignment.TopEnd) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Column(Modifier.align(Alignment.BottomEnd)) { | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier.align(Alignment.End), | ||||||
|  |                         text = when (model.status) { | ||||||
|  |                             Status.COMPLETED -> "Completed" | ||||||
|  |                             Status.PAUSED, Status.QUEUED -> "Paused" | ||||||
|  |                             Status.DOWNLOADING -> "Downloading" | ||||||
|  |                             else -> "Error" | ||||||
|  |                         }, | ||||||
|  |                         fontSize = 10.sp, | ||||||
|  |                         lineHeight = 11.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     Row(Modifier | ||||||
|  |                         .align(Alignment.End) | ||||||
|  |                         .padding(vertical = 2.dp)) { | ||||||
|  |                         Text( | ||||||
|  |                             modifier = Modifier, | ||||||
|  |                             text = "%.2f MB/%.2f MB".format( | ||||||
|  |                                 model.downloadedBytes / (1024.0 * 1024.0), | ||||||
|  |                                 model.totalBytes / (1024.0 * 1024.0) | ||||||
|  |                             ), | ||||||
|  |                             fontSize = 10.sp, | ||||||
|  |                             lineHeight = 11.sp, | ||||||
|  |                             fontWeight = FontWeight.Bold, | ||||||
|  |                             maxLines = 1, | ||||||
|  |                         ) | ||||||
|  |                         Spacer(Modifier.width(12.dp)) | ||||||
|  |                         Text( | ||||||
|  |                             text = "${model.progress.coerceIn(0, 100)}%", | ||||||
|  |                             modifier = Modifier, | ||||||
|  |                             fontSize = 10.sp, | ||||||
|  |                             lineHeight = 11.sp, | ||||||
|  |                             fontWeight = FontWeight.Bold, | ||||||
|  |                             maxLines = 1, | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                     BiliMiniSlider( | ||||||
|  |                         value = abs(model.progress).coerceIn(0, 100) / 100f, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .height(6.dp) | ||||||
|  |                             .align(Alignment.End) | ||||||
|  |                             .fillMaxWidth(), | ||||||
|  |                         onValueChange = { | ||||||
|  |  | ||||||
|  |                         }, | ||||||
|  |                         colors = when(model.status) | ||||||
|  |                         { | ||||||
|  |                             Status.DOWNLOADING, Status.QUEUED, Status.ADDED -> SliderDefaults.colors( | ||||||
|  |                                 thumbColor = Color(0xFFFFFFFF), | ||||||
|  |                                 activeTrackColor = MaterialTheme.colorScheme.primary, | ||||||
|  |                                 inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |                             ) | ||||||
|  |                             Status.PAUSED -> SliderDefaults.colors( | ||||||
|  |                                 thumbColor = Color(0xFFFFFFFF), | ||||||
|  |                                 activeTrackColor = Color(0xFFFFA500), | ||||||
|  |                                 inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |                             ) | ||||||
|  |                             Status.COMPLETED -> SliderDefaults.colors( | ||||||
|  |                                 thumbColor = Color(0xFFFFFFFF), | ||||||
|  |                                 activeTrackColor = MaterialTheme.colorScheme.primary, | ||||||
|  |                                 inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |                             ) | ||||||
|  |                             else -> SliderDefaults.colors( | ||||||
|  |                                 thumbColor = Color(0xFFFFFFFF), | ||||||
|  |                                 activeTrackColor = MaterialTheme.colorScheme.error, | ||||||
|  |                                 inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,627 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import android.app.Activity | ||||||
|  | import android.content.Context | ||||||
|  | import android.media.AudioManager | ||||||
|  | import android.view.View | ||||||
|  | import androidx.activity.compose.BackHandler | ||||||
|  | import androidx.annotation.OptIn | ||||||
|  | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | import androidx.compose.animation.fadeIn | ||||||
|  | import androidx.compose.animation.fadeOut | ||||||
|  | import androidx.compose.animation.slideInHorizontally | ||||||
|  | import androidx.compose.animation.slideInVertically | ||||||
|  | import androidx.compose.animation.slideOutHorizontally | ||||||
|  | import androidx.compose.animation.slideOutVertically | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.gestures.detectDragGestures | ||||||
|  | import androidx.compose.foundation.gestures.detectTapGestures | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.PaddingValues | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.size | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.foundation.lazy.rememberLazyListState | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.automirrored.filled.ArrowBack | ||||||
|  | import androidx.compose.material.icons.automirrored.filled.List | ||||||
|  | import androidx.compose.material.icons.automirrored.filled.VolumeUp | ||||||
|  | import androidx.compose.material.icons.filled.Brightness4 | ||||||
|  | import androidx.compose.material.icons.filled.FastForward | ||||||
|  | import androidx.compose.material.icons.filled.FullscreenExit | ||||||
|  | import androidx.compose.material.icons.filled.Lock | ||||||
|  | import androidx.compose.material.icons.filled.LockOpen | ||||||
|  | import androidx.compose.material.icons.filled.Pause | ||||||
|  | import androidx.compose.material.icons.filled.PlayArrow | ||||||
|  | import androidx.compose.material.icons.filled.SkipNext | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.IconButton | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.DisposableEffect | ||||||
|  | import androidx.compose.runtime.LaunchedEffect | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableFloatStateOf | ||||||
|  | import androidx.compose.runtime.remember | ||||||
|  | import androidx.compose.runtime.setValue | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.graphics.Brush | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.input.pointer.pointerInput | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.platform.LocalView | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.compose.ui.viewinterop.AndroidView | ||||||
|  | import androidx.lifecycle.viewModelScope | ||||||
|  | import androidx.media3.common.util.UnstableApi | ||||||
|  | import androidx.media3.exoplayer.ExoPlayer | ||||||
|  | import androidx.media3.ui.PlayerView | ||||||
|  | import com.acitelight.aether.setFullScreen | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  | import com.acitelight.aether.view.pages.moveBrit | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  | import kotlinx.coroutines.launch | ||||||
|  | import kotlin.math.abs | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @OptIn(UnstableApi::class) | ||||||
|  | @Composable | ||||||
|  | fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     val context = LocalContext.current | ||||||
|  |     val activity = (context as? Activity)!! | ||||||
|  |     val exoPlayer: ExoPlayer = videoPlayerViewModel.player!! | ||||||
|  |  | ||||||
|  |     val name by videoPlayerViewModel.currentName | ||||||
|  |     val id by videoPlayerViewModel.currentId | ||||||
|  |     val listState = rememberLazyListState() | ||||||
|  |     val videos = videoPlayerViewModel.videos | ||||||
|  |  | ||||||
|  |     LaunchedEffect(id, videos) { | ||||||
|  |         val targetIndex = videos.indexOfFirst { it.id == id } | ||||||
|  |         if (targetIndex >= 0) { | ||||||
|  |             listState.scrollToItem(targetIndex) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager } | ||||||
|  |     val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) } | ||||||
|  |     var volFactor by remember { | ||||||
|  |         mutableFloatStateOf( | ||||||
|  |             audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume.toFloat() | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun setVolume(value: Int) { | ||||||
|  |         audioManager.setStreamVolume( | ||||||
|  |             AudioManager.STREAM_MUSIC, | ||||||
|  |             value.coerceIn(0, maxVolume), | ||||||
|  |             AudioManager.FLAG_PLAY_SOUND | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     BackHandler { | ||||||
|  |         videoPlayerViewModel.isLandscape = false | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val view = LocalView.current | ||||||
|  |     DisposableEffect(Unit) { | ||||||
|  |         setFullScreen(view, true) | ||||||
|  |         onDispose { | ||||||
|  |             setFullScreen(view, false) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Box(Modifier.fillMaxSize()) | ||||||
|  |     { | ||||||
|  |         Box( | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .background(Color.Black) | ||||||
|  |                 .align(Alignment.Center) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Box( | ||||||
|  |                 Modifier | ||||||
|  |                     .fillMaxSize() | ||||||
|  |                     .pointerInput(videoPlayerViewModel) { | ||||||
|  |                         detectDragGestures( | ||||||
|  |                             onDragStart = { offset -> | ||||||
|  |                                 if (videoPlayerViewModel.locked) return@detectDragGestures | ||||||
|  |                                 if (offset.y > size.height * 0.9 || offset.y < size.height * 0.1) | ||||||
|  |                                     videoPlayerViewModel.draggingPurpose = -3 | ||||||
|  |                                 // Set gesture protection for the bottom of the screen | ||||||
|  |                                 // (Prevent conflicts with system gestures, such as dropdown status bar, bottom swipe up menu) | ||||||
|  |                                 else if (offset.x < size.width / 2) { | ||||||
|  |                                     videoPlayerViewModel.draggingPurpose = -1 | ||||||
|  |                                 } else { | ||||||
|  |                                     videoPlayerViewModel.draggingPurpose = -2 | ||||||
|  |                                 } | ||||||
|  |                             }, | ||||||
|  |                             onDragEnd = { | ||||||
|  |                                 if (videoPlayerViewModel.isPlaying && videoPlayerViewModel.draggingPurpose == 0) | ||||||
|  |                                     exoPlayer.play() | ||||||
|  |  | ||||||
|  |                                 videoPlayerViewModel.draggingPurpose = -1 | ||||||
|  |                             }, | ||||||
|  |                             onDrag = { change, dragAmount -> | ||||||
|  |                                 if (videoPlayerViewModel.locked) return@detectDragGestures | ||||||
|  |                                 if (abs(dragAmount.x) > abs(dragAmount.y) && | ||||||
|  |                                     (videoPlayerViewModel.draggingPurpose == -1 || videoPlayerViewModel.draggingPurpose == -2) | ||||||
|  |                                 ) { | ||||||
|  |                                     videoPlayerViewModel.draggingPurpose = 0 | ||||||
|  |                                     videoPlayerViewModel.planeVisibility = true | ||||||
|  |                                     exoPlayer.pause() | ||||||
|  |                                 } else if (videoPlayerViewModel.draggingPurpose == -1) videoPlayerViewModel.draggingPurpose = | ||||||
|  |                                     1 | ||||||
|  |                                 else if (videoPlayerViewModel.draggingPurpose == -2) videoPlayerViewModel.draggingPurpose = | ||||||
|  |                                     2 | ||||||
|  |  | ||||||
|  |                                 if (videoPlayerViewModel.draggingPurpose == 0) { | ||||||
|  |                                     exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong()) | ||||||
|  |                                     videoPlayerViewModel.playProcess = | ||||||
|  |                                         exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat() | ||||||
|  |                                 } else if (videoPlayerViewModel.draggingPurpose == 2) { | ||||||
|  |                                     val cu = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) | ||||||
|  |                                     volFactor = | ||||||
|  |                                         audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) | ||||||
|  |                                             .toFloat() / maxVolume.toFloat() | ||||||
|  |                                     if (dragAmount.y < 0) | ||||||
|  |                                         setVolume(cu + 1) | ||||||
|  |                                     else if (dragAmount.y > 0) | ||||||
|  |                                         setVolume(cu - 1) | ||||||
|  |                                 } else if (videoPlayerViewModel.draggingPurpose == 1) { | ||||||
|  |                                     moveBrit(dragAmount.y, activity, videoPlayerViewModel) | ||||||
|  |                                 } | ||||||
|  |  | ||||||
|  |                             } | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                     .pointerInput(videoPlayerViewModel) { | ||||||
|  |                         detectTapGestures( | ||||||
|  |                             onDoubleTap = { | ||||||
|  |                                 if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |  | ||||||
|  |                                 videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying | ||||||
|  |                                 if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() | ||||||
|  |                             }, | ||||||
|  |                             onTap = { | ||||||
|  |                                 if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |                                 if (videoPlayerViewModel.showPlaylist) { | ||||||
|  |                                     videoPlayerViewModel.showPlaylist = false | ||||||
|  |                                     return@detectTapGestures | ||||||
|  |                                 } | ||||||
|  |  | ||||||
|  |                                 videoPlayerViewModel.planeVisibility = | ||||||
|  |                                     !videoPlayerViewModel.planeVisibility | ||||||
|  |                             }, | ||||||
|  |                             onLongPress = { | ||||||
|  |                                 if (videoPlayerViewModel.locked) return@detectTapGestures | ||||||
|  |  | ||||||
|  |                                 videoPlayerViewModel.isLongPressing = true | ||||||
|  |                                 exoPlayer.playbackParameters = exoPlayer.playbackParameters | ||||||
|  |                                     .withSpeed(3.0f) | ||||||
|  |                             }, | ||||||
|  |                             onPress = { offset -> | ||||||
|  |                                 val pressResult = tryAwaitRelease() | ||||||
|  |                                 if (pressResult && videoPlayerViewModel.isLongPressing) { | ||||||
|  |                                     videoPlayerViewModel.isLongPressing = false | ||||||
|  |                                     exoPlayer.playbackParameters = exoPlayer.playbackParameters | ||||||
|  |                                         .withSpeed(1.0f) | ||||||
|  |                                 } | ||||||
|  |                             }, | ||||||
|  |                         ) | ||||||
|  |                     }) { | ||||||
|  |                 AndroidView( | ||||||
|  |                     factory = { | ||||||
|  |                         PlayerView( | ||||||
|  |                             it | ||||||
|  |                         ).apply { | ||||||
|  |                             player = exoPlayer | ||||||
|  |                             useController = false | ||||||
|  |                             subtitleView?.let { sv -> | ||||||
|  |                                 sv.visibility = View.GONE | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.Center) | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.draggingPurpose == 0, | ||||||
|  |                 enter = fadeIn( | ||||||
|  |                     initialAlpha = 0f, | ||||||
|  |                 ), | ||||||
|  |                 exit = fadeOut( | ||||||
|  |                     targetAlpha = 0f | ||||||
|  |                 ), | ||||||
|  |                 modifier = Modifier.align(Alignment.Center) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Text( | ||||||
|  |                     text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${ | ||||||
|  |                         formatTime(exoPlayer.duration) | ||||||
|  |                     }", | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                     modifier = Modifier.padding(bottom = 12.dp), | ||||||
|  |                     fontSize = 18.sp | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.draggingPurpose == 2, | ||||||
|  |                 enter = fadeIn( | ||||||
|  |                     initialAlpha = 0f, | ||||||
|  |                 ), | ||||||
|  |                 exit = fadeOut( | ||||||
|  |                     targetAlpha = 0f | ||||||
|  |                 ), | ||||||
|  |                 modifier = Modifier.align(Alignment.Center) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Row( | ||||||
|  |                     Modifier | ||||||
|  |                         .background(Color(0x88000000), RoundedCornerShape(18)) | ||||||
|  |                         .width(200.dp) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = Icons.AutoMirrored.Filled.VolumeUp, | ||||||
|  |                         contentDescription = "Vol", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .size(48.dp) | ||||||
|  |                             .padding(8.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) | ||||||
|  |                     BiliMiniSlider( | ||||||
|  |                         value = volFactor, | ||||||
|  |                         onValueChange = {}, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .height(4.dp) | ||||||
|  |                             .padding(horizontal = 8.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.draggingPurpose == 1, | ||||||
|  |                 enter = fadeIn( | ||||||
|  |                     initialAlpha = 0f, | ||||||
|  |                 ), | ||||||
|  |                 exit = fadeOut( | ||||||
|  |                     targetAlpha = 0f | ||||||
|  |                 ), | ||||||
|  |                 modifier = Modifier.align(Alignment.Center) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Row( | ||||||
|  |                     Modifier | ||||||
|  |                         .background(Color(0x88000000), RoundedCornerShape(18)) | ||||||
|  |                         .width(200.dp) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = Icons.Default.Brightness4, | ||||||
|  |                         contentDescription = "Brightness", | ||||||
|  |                         tint = Color.White, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .size(48.dp) | ||||||
|  |                             .padding(8.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) | ||||||
|  |                     BiliMiniSlider( | ||||||
|  |                         value = videoPlayerViewModel.brit, | ||||||
|  |                         onValueChange = {}, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .height(4.dp) | ||||||
|  |                             .padding(horizontal = 8.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.isLongPressing, | ||||||
|  |                 enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |                 exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.TopCenter) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Box( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.TopCenter) | ||||||
|  |                         .padding(top = 24.dp) | ||||||
|  |                         .background(Color(0x44000000), RoundedCornerShape(18)) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     Row { | ||||||
|  |                         Icon( | ||||||
|  |                             imageVector = Icons.Filled.FastForward, | ||||||
|  |                             contentDescription = "Fast Forward", | ||||||
|  |                             tint = Color.White, | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .size(36.dp) | ||||||
|  |                                 .padding(4.dp) | ||||||
|  |                                 .align(Alignment.CenterVertically) | ||||||
|  |                         ) | ||||||
|  |  | ||||||
|  |                         Text( | ||||||
|  |                             text = "3X Speed...", | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .padding(4.dp) | ||||||
|  |                                 .align(Alignment.CenterVertically), | ||||||
|  |                             fontSize = 16.sp, | ||||||
|  |                             fontWeight = FontWeight.Bold, | ||||||
|  |                             color = Color(0xFFFFFFFF) | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.planeVisibility && (!videoPlayerViewModel.locked), | ||||||
|  |                 enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |                 exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }), | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.TopCenter) | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Row( | ||||||
|  |                     Modifier | ||||||
|  |                         .align(Alignment.TopStart) | ||||||
|  |                         .background( | ||||||
|  |                             brush = Brush.verticalGradient( | ||||||
|  |                                 colors = listOf( | ||||||
|  |                                     Color.Black.copy(alpha = 0.4f), | ||||||
|  |                                     Color.Transparent, | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     IconButton( | ||||||
|  |                         onClick = { | ||||||
|  |                             videoPlayerViewModel.isLandscape = false | ||||||
|  |                         }, | ||||||
|  |                         Modifier | ||||||
|  |                             .padding(top = 12.dp) | ||||||
|  |                             .padding(start = 46.dp) | ||||||
|  |                             .size(36.dp) | ||||||
|  |                             .align(Alignment.CenterVertically) | ||||||
|  |                     ) { | ||||||
|  |                         Icon( | ||||||
|  |                             Icons.AutoMirrored.Filled.ArrowBack, | ||||||
|  |                             contentDescription = "Back", | ||||||
|  |                             tint = Color.White, | ||||||
|  |                             modifier = Modifier.size(32.dp) | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     Text( | ||||||
|  |                         text = name, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(top = 12.dp) | ||||||
|  |                             .align(Alignment.CenterVertically), | ||||||
|  |                         fontSize = 18.sp | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.planeVisibility && (!videoPlayerViewModel.locked), | ||||||
|  |                 enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }), | ||||||
|  |                 exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }), | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.BottomCenter) | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Column( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.BottomCenter) | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .background( | ||||||
|  |                             brush = Brush.verticalGradient( | ||||||
|  |                                 colors = listOf( | ||||||
|  |                                     Color.Transparent, | ||||||
|  |                                     Color.Black.copy(alpha = 0.4f) | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                         .padding(horizontal = 36.dp) | ||||||
|  |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${ | ||||||
|  |                             formatTime(exoPlayer.duration) | ||||||
|  |                         }", | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         modifier = Modifier.padding(bottom = 12.dp), | ||||||
|  |                         fontSize = 12.sp | ||||||
|  |                     ) | ||||||
|  |                     BiliStyleSlider( | ||||||
|  |                         value = videoPlayerViewModel.playProcess, | ||||||
|  |                         onValueChange = { value -> | ||||||
|  |                             exoPlayer.seekTo((exoPlayer.duration * value).toLong()) | ||||||
|  |                         }, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .height(16.dp) | ||||||
|  |                             .fillMaxWidth() | ||||||
|  |                             .padding(bottom = 8.dp) | ||||||
|  |                     ) | ||||||
|  |                     Row( | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .fillMaxWidth() | ||||||
|  |                             .padding(bottom = 8.dp) | ||||||
|  |                             .align(Alignment.Start), | ||||||
|  |                         horizontalArrangement = Arrangement.SpaceBetween, | ||||||
|  |                     ) { | ||||||
|  |                         IconButton( | ||||||
|  |                             onClick = { | ||||||
|  |                                 videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying | ||||||
|  |                                 if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause() | ||||||
|  |                             }, | ||||||
|  |                             Modifier.size(42.dp) | ||||||
|  |                         ) { | ||||||
|  |                             Icon( | ||||||
|  |                                 imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, | ||||||
|  |                                 contentDescription = "Play/Pause", | ||||||
|  |                                 tint = Color.White, | ||||||
|  |                                 modifier = Modifier.size(42.dp) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         IconButton( | ||||||
|  |                             onClick = { | ||||||
|  |                                 videoPlayerViewModel.viewModelScope.launch { | ||||||
|  |                                     videoPlayerViewModel.startPlay( | ||||||
|  |                                         videoPlayerViewModel.videos.getOrNull(videoPlayerViewModel.videos.indexOf( | ||||||
|  |                                             videoPlayerViewModel.videos.first { | ||||||
|  |                                                 it.id == videoPlayerViewModel.currentId.value | ||||||
|  |                                             }) + 1) ?: videoPlayerViewModel.videos.first() | ||||||
|  |                                     ) | ||||||
|  |                                 } | ||||||
|  |                             }, | ||||||
|  |                             Modifier.size(42.dp) | ||||||
|  |                         ) { | ||||||
|  |                             Icon( | ||||||
|  |                                 imageVector = Icons.Default.SkipNext, | ||||||
|  |                                 contentDescription = "Next", | ||||||
|  |                                 tint = Color.White, | ||||||
|  |                                 modifier = Modifier.size(42.dp) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         Spacer(Modifier.weight(1f)) | ||||||
|  |  | ||||||
|  |                         IconButton( | ||||||
|  |                             onClick = { | ||||||
|  |                                 videoPlayerViewModel.isLandscape = false | ||||||
|  |                             }, | ||||||
|  |                             Modifier | ||||||
|  |                                 .size(36.dp) | ||||||
|  |                                 .align(Alignment.CenterVertically) | ||||||
|  |                         ) { | ||||||
|  |                             Icon( | ||||||
|  |                                 Icons.Default.FullscreenExit, | ||||||
|  |                                 contentDescription = "Exit FullScreen", | ||||||
|  |                                 tint = Color.White, | ||||||
|  |                                 modifier = Modifier.size(32.dp) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         IconButton( | ||||||
|  |                             onClick = { | ||||||
|  |                                 videoPlayerViewModel.showPlaylist = true | ||||||
|  |                             }, | ||||||
|  |                             Modifier | ||||||
|  |                                 .size(36.dp) | ||||||
|  |                                 .align(Alignment.CenterVertically) | ||||||
|  |                         ) { | ||||||
|  |                             Icon( | ||||||
|  |                                 Icons.AutoMirrored.Filled.List, | ||||||
|  |                                 contentDescription = "Playlist", | ||||||
|  |                                 tint = Color.White, | ||||||
|  |                                 modifier = Modifier.size(32.dp) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.locked || videoPlayerViewModel.planeVisibility, | ||||||
|  |                 enter = fadeIn( | ||||||
|  |                     initialAlpha = 0f, | ||||||
|  |                 ), | ||||||
|  |                 exit = fadeOut( | ||||||
|  |                     targetAlpha = 0f | ||||||
|  |                 ), | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterEnd) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Card( | ||||||
|  |                     modifier = Modifier.padding(4.dp), | ||||||
|  |                     colors = CardDefaults.cardColors( | ||||||
|  |                         containerColor = colorScheme.primary.copy( | ||||||
|  |                             if (videoPlayerViewModel.locked) 0.2f else 1f | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                     onClick = { | ||||||
|  |                         videoPlayerViewModel.locked = !videoPlayerViewModel.locked | ||||||
|  |                     }) { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = if (videoPlayerViewModel.locked) Icons.Default.LockOpen else Icons.Default.Lock, | ||||||
|  |                         contentDescription = "Lock", | ||||||
|  |                         tint = Color.White.copy(if (videoPlayerViewModel.locked) 0.2f else 1f), | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .size(36.dp) | ||||||
|  |                             .padding(6.dp) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.showPlaylist, | ||||||
|  |                 enter = slideInHorizontally(initialOffsetX = { full -> full }), | ||||||
|  |                 exit = slideOutHorizontally(targetOffsetX = { full -> full }), | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterEnd) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Card( | ||||||
|  |                     Modifier | ||||||
|  |                         .fillMaxHeight() | ||||||
|  |                         .width(320.dp) | ||||||
|  |                         .align(Alignment.CenterEnd), | ||||||
|  |                     shape = RoundedCornerShape(8.dp), | ||||||
|  |                     colors = CardDefaults.cardColors(containerColor = colorScheme.surface.copy(0.75f)) | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     LazyColumn(state = listState, contentPadding = PaddingValues(vertical = 4.dp)) { | ||||||
|  |                         items(videoPlayerViewModel.videos) { item -> | ||||||
|  |                             MiniPlaylistCard(Modifier.padding(4.dp), video = item, imageLoader = videoPlayerViewModel.imageLoader!!, | ||||||
|  |                                 selected = id == item.id, apiClient = videoPlayerViewModel.apiClient) | ||||||
|  |                             { | ||||||
|  |                                 if (name == item.video.name) | ||||||
|  |                                     return@MiniPlaylistCard | ||||||
|  |  | ||||||
|  |                                 videoPlayerViewModel.viewModelScope.launch { | ||||||
|  |                                     videoPlayerViewModel.startPlay(item) | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             SubtitleOverlay( | ||||||
|  |                 cues = videoPlayerViewModel.cues, | ||||||
|  |                 modifier = Modifier.matchParentSize() | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,277 @@ | |||||||
|  | package com.acitelight.aether.view.components | ||||||
|  |  | ||||||
|  | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | import androidx.compose.animation.fadeIn | ||||||
|  | import androidx.compose.animation.fadeOut | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.size | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.rememberLazyListState | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.filled.Lock | ||||||
|  | import androidx.compose.material.icons.filled.LockOpen | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.DividerDefaults | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Tab | ||||||
|  | import androidx.compose.material3.TabRow | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableFloatStateOf | ||||||
|  | import androidx.compose.runtime.mutableStateOf | ||||||
|  | import androidx.compose.runtime.remember | ||||||
|  | import androidx.compose.runtime.setValue | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.alpha | ||||||
|  | import androidx.compose.ui.geometry.Offset | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection | ||||||
|  | import androidx.compose.ui.input.nestedscroll.NestedScrollSource | ||||||
|  | import androidx.compose.ui.input.nestedscroll.nestedScroll | ||||||
|  | import androidx.compose.ui.layout.onGloballyPositioned | ||||||
|  | import androidx.compose.ui.platform.LocalConfiguration | ||||||
|  | import androidx.compose.ui.platform.LocalDensity | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import com.acitelight.aether.Global | ||||||
|  | import com.acitelight.aether.view.pages.formatTime | ||||||
|  | import com.acitelight.aether.view.pages.toHex | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VideoPlayerPortal( | ||||||
|  |     videoPlayerViewModel: VideoPlayerViewModel, | ||||||
|  |     navController: NavHostController | ||||||
|  | ) { | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     val configuration = LocalConfiguration.current | ||||||
|  |     val screenHeight = configuration.screenHeightDp.dp | ||||||
|  |  | ||||||
|  |     val minHeight = 42.dp | ||||||
|  |     var coverAlpha by remember { mutableFloatStateOf(0.0f) } | ||||||
|  |     var maxHeight = remember { screenHeight * 0.65f } | ||||||
|  |     var posed = remember { false } | ||||||
|  |     val dens = LocalDensity.current | ||||||
|  |     val listState = rememberLazyListState() | ||||||
|  |  | ||||||
|  |     var playerHeight by remember { mutableStateOf(screenHeight * 0.65f) } | ||||||
|  |  | ||||||
|  |     val nestedScrollConnection = remember { | ||||||
|  |         object : NestedScrollConnection { | ||||||
|  |             override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { | ||||||
|  |                 val deltaY = available.y // px | ||||||
|  |                 val deltaDp = with(dens) { deltaY.toDp() } | ||||||
|  |  | ||||||
|  |                 val r = if (deltaY < 0 && playerHeight > minHeight) { | ||||||
|  |                     val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight) | ||||||
|  |                     val consumedDp = newHeight - playerHeight | ||||||
|  |                     playerHeight = newHeight | ||||||
|  |                     val consumedPx = with(dens) { consumedDp.toPx() } | ||||||
|  |                     Offset(0f, consumedPx) | ||||||
|  |                 } else if (deltaY > 0 && playerHeight < maxHeight && listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0) { | ||||||
|  |                     val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight) | ||||||
|  |                     val consumedDp = newHeight - playerHeight | ||||||
|  |                     playerHeight = newHeight | ||||||
|  |                     val consumedPx = with(dens) { consumedDp.toPx() } | ||||||
|  |                     Offset(0f, consumedPx) | ||||||
|  |                 } else { | ||||||
|  |                     Offset.Zero | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 val dh = playerHeight - minHeight | ||||||
|  |                 coverAlpha = (if (dh > 10.dp) | ||||||
|  |                     0f | ||||||
|  |                 else | ||||||
|  |                     (10.dp.value - dh.value) / 10.0f) | ||||||
|  |  | ||||||
|  |                 return r | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val klass by videoPlayerViewModel.currentKlass | ||||||
|  |     val id by videoPlayerViewModel.currentId | ||||||
|  |     val name by videoPlayerViewModel.currentName | ||||||
|  |     val duration by videoPlayerViewModel.currentDuration | ||||||
|  |  | ||||||
|  |     Column( | ||||||
|  |         Modifier | ||||||
|  |             .nestedScroll(nestedScrollConnection) | ||||||
|  |             .fillMaxHeight() | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         Box { | ||||||
|  |             PortalCorePlayer( | ||||||
|  |                 Modifier | ||||||
|  |                     .heightIn(max = playerHeight) | ||||||
|  |                     .onGloballyPositioned { layoutCoordinates -> | ||||||
|  |                         if (!posed && videoPlayerViewModel.renderedFirst) { | ||||||
|  |                             maxHeight = with(dens) { layoutCoordinates.size.height.toDp() } | ||||||
|  |                             playerHeight = maxHeight | ||||||
|  |                             posed = true | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                 videoPlayerViewModel = videoPlayerViewModel, coverAlpha | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             androidx.compose.animation.AnimatedVisibility( | ||||||
|  |                 visible = videoPlayerViewModel.locked || videoPlayerViewModel.planeVisibility, | ||||||
|  |                 enter = fadeIn( | ||||||
|  |                     initialAlpha = 0f, | ||||||
|  |                 ), | ||||||
|  |                 exit = fadeOut( | ||||||
|  |                     targetAlpha = 0f | ||||||
|  |                 ), | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterEnd) | ||||||
|  |             ) { | ||||||
|  |                 Card( | ||||||
|  |                     modifier = Modifier.padding(4.dp), | ||||||
|  |                     colors = CardDefaults.cardColors( | ||||||
|  |                         containerColor = colorScheme.primary.copy( | ||||||
|  |                             if (videoPlayerViewModel.locked) 0.2f else 1f | ||||||
|  |                         ) | ||||||
|  |                     ), | ||||||
|  |                     onClick = { | ||||||
|  |                         videoPlayerViewModel.locked = !videoPlayerViewModel.locked | ||||||
|  |                     }) { | ||||||
|  |                     Icon( | ||||||
|  |                         imageVector = if (videoPlayerViewModel.locked) Icons.Default.LockOpen else Icons.Default.Lock, | ||||||
|  |                         contentDescription = "Lock", | ||||||
|  |                         tint = Color.White.copy(if (videoPlayerViewModel.locked) 0.2f else 1f), | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .size(36.dp) | ||||||
|  |                             .padding(6.dp) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Row() | ||||||
|  |         { | ||||||
|  |             TabRow( | ||||||
|  |                 selectedTabIndex = videoPlayerViewModel.tabIndex, | ||||||
|  |                 modifier = Modifier.height(38.dp) | ||||||
|  |             ) { | ||||||
|  |                 Tab( | ||||||
|  |                     selected = videoPlayerViewModel.tabIndex == 0, | ||||||
|  |                     onClick = { videoPlayerViewModel.tabIndex = 0 }, | ||||||
|  |                     text = { Text(text = "Introduction", maxLines = 1) }, | ||||||
|  |                     modifier = Modifier.height(38.dp) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Tab( | ||||||
|  |                     selected = videoPlayerViewModel.tabIndex == 1, | ||||||
|  |                     onClick = { videoPlayerViewModel.tabIndex = 1 }, | ||||||
|  |                     text = { Text(text = "Comment", maxLines = 1) }, | ||||||
|  |                     modifier = Modifier.height(38.dp) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         LazyColumn(state = listState, modifier = Modifier.fillMaxWidth()) { | ||||||
|  |             item { | ||||||
|  |                 Text( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .align(Alignment.Start) | ||||||
|  |                         .padding(horizontal = 12.dp) | ||||||
|  |                         .padding(top = 12.dp), | ||||||
|  |                     text = name, | ||||||
|  |                     fontSize = 16.sp, | ||||||
|  |                     maxLines = 2, | ||||||
|  |                     fontWeight = FontWeight.Bold, | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 Row( | ||||||
|  |                     Modifier | ||||||
|  |                         .align(Alignment.Start) | ||||||
|  |                         .padding(horizontal = 4.dp) | ||||||
|  |                         .alpha(0.5f) | ||||||
|  |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier.padding(horizontal = 8.dp), | ||||||
|  |                         text = "$klass.$id", | ||||||
|  |                         fontSize = 14.sp, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     Text( | ||||||
|  |                         modifier = Modifier.padding(horizontal = 8.dp), | ||||||
|  |                         text = formatTime(duration), | ||||||
|  |                         fontSize = 14.sp, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color) | ||||||
|  |  | ||||||
|  |                 if (videoPlayerViewModel.videos.size > 1) { | ||||||
|  |                     PlaylistPanel( | ||||||
|  |                         Modifier, | ||||||
|  |                         videoPlayerViewModel = videoPlayerViewModel | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     HorizontalDivider( | ||||||
|  |                         Modifier.padding(vertical = 8.dp), | ||||||
|  |                         1.dp, | ||||||
|  |                         DividerDefaults.color | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 HorizontalGallery(videoPlayerViewModel) | ||||||
|  |                 HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color) | ||||||
|  |  | ||||||
|  |                 for (i in Global.sameClassVideos ?: listOf()) { | ||||||
|  |                     if (i.id == id) continue | ||||||
|  |  | ||||||
|  |                     MiniVideoCard( | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(horizontal = 12.dp), | ||||||
|  |                         i, | ||||||
|  |                         apiClient = videoPlayerViewModel.apiClient, | ||||||
|  |                         imageLoader = videoPlayerViewModel.imageLoader!! | ||||||
|  |                     ) { | ||||||
|  |                         videoPlayerViewModel.isPlaying = false | ||||||
|  |                         videoPlayerViewModel.player?.pause() | ||||||
|  |  | ||||||
|  |                         val playList = mutableListOf<String>() | ||||||
|  |                         val fv = | ||||||
|  |                             videoPlayerViewModel.videoLibrary.classesMap.map { it.value }.flatten() | ||||||
|  |  | ||||||
|  |                         val group = | ||||||
|  |                             fv.filter { it.klass == i.klass && it.video.group == i.video.group } | ||||||
|  |                         for (i in group) { | ||||||
|  |                             playList.add("${i.klass}/${i.id}") | ||||||
|  |                         } | ||||||
|  |  | ||||||
|  |                         val route = "video_player_route/${(playList.joinToString(",") + "|${i.id}").toHex()}" | ||||||
|  |                         navController.navigate(route) | ||||||
|  |                     } | ||||||
|  |                     HorizontalDivider( | ||||||
|  |                         Modifier | ||||||
|  |                             .padding(vertical = 8.dp) | ||||||
|  |                             .alpha(0.25f), | ||||||
|  |                         1.dp, | ||||||
|  |                         DividerDefaults.color | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,438 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.clickable | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.FlowRow | ||||||
|  | import androidx.compose.foundation.layout.PaddingValues | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.layout.widthIn | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.LazyRow | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.foundation.lazy.rememberLazyListState | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.LazyHorizontalStaggeredGrid | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState | ||||||
|  | import androidx.compose.foundation.pager.HorizontalPager | ||||||
|  | import androidx.compose.foundation.pager.rememberPagerState | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.material3.Button | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.DisposableEffect | ||||||
|  | import androidx.compose.runtime.LaunchedEffect | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableStateOf | ||||||
|  | import androidx.compose.runtime.remember | ||||||
|  | import androidx.compose.runtime.setValue | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.geometry.Offset | ||||||
|  | import androidx.compose.ui.graphics.Brush | ||||||
|  | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection | ||||||
|  | import androidx.compose.ui.input.nestedscroll.NestedScrollSource | ||||||
|  | import androidx.compose.ui.input.nestedscroll.nestedScroll | ||||||
|  | import androidx.compose.ui.layout.ContentScale | ||||||
|  | import androidx.compose.ui.platform.LocalConfiguration | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.platform.LocalDensity | ||||||
|  | import androidx.compose.ui.platform.LocalView | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.min | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  |  | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import coil3.compose.AsyncImage | ||||||
|  | import coil3.request.ImageRequest | ||||||
|  | import com.acitelight.aether.model.BookMark | ||||||
|  | import com.acitelight.aether.model.Comic | ||||||
|  | import com.acitelight.aether.setFullScreen | ||||||
|  | import com.acitelight.aether.view.components.BiliMiniSlider | ||||||
|  | import com.acitelight.aether.viewModel.ComicGridViewModel | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun ComicGridView( | ||||||
|  |     comicId: String, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     comicGridViewModel: ComicGridViewModel = hiltViewModel<ComicGridViewModel>() | ||||||
|  | ) { | ||||||
|  |     comicGridViewModel.resolve(comicId.hexToString()) | ||||||
|  |     comicGridViewModel.updateProcess(comicId.hexToString()) {} | ||||||
|  |     val configuration = LocalConfiguration.current | ||||||
|  |     val screenHeight = configuration.screenHeightDp.dp | ||||||
|  |     val screenWidth = configuration.screenWidthDp.dp | ||||||
|  |     val record by comicGridViewModel.record | ||||||
|  |     val comic by comicGridViewModel.comic | ||||||
|  |  | ||||||
|  |     val view = LocalView.current | ||||||
|  |     DisposableEffect(Unit) { | ||||||
|  |         setFullScreen(view, true) | ||||||
|  |         onDispose { | ||||||
|  |             val nextRoute = navController.currentBackStackEntry?.destination?.route | ||||||
|  |             if (nextRoute?.startsWith("comic_page_route") != true) { | ||||||
|  |                 setFullScreen(view, false) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     LaunchedEffect(comicGridViewModel) { | ||||||
|  |         comicGridViewModel.coverHeight = screenHeight * 0.3f | ||||||
|  |         if(comicGridViewModel.maxHeight == 0.dp) | ||||||
|  |             comicGridViewModel.maxHeight = screenHeight * 0.8f | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     val dens = LocalDensity.current | ||||||
|  |     val listState = rememberLazyListState() | ||||||
|  |  | ||||||
|  |     val nestedScrollConnection = remember { | ||||||
|  |         object : NestedScrollConnection { | ||||||
|  |             override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { | ||||||
|  |                 val deltaY = available.y // px | ||||||
|  |                 val deltaDp = with(dens) { deltaY.toDp() } | ||||||
|  |  | ||||||
|  |                 val r = if (deltaY < 0 && comicGridViewModel.coverHeight > 0.dp) { | ||||||
|  |                     val newHeight = (comicGridViewModel.coverHeight + deltaDp).coerceIn(0.dp, comicGridViewModel.maxHeight) | ||||||
|  |                     val consumedDp = newHeight - comicGridViewModel.coverHeight | ||||||
|  |                     comicGridViewModel.coverHeight = newHeight | ||||||
|  |                     val consumedPx = with(dens) { consumedDp.toPx() } | ||||||
|  |                     Offset(0f, consumedPx) | ||||||
|  |                 } else if ( | ||||||
|  |                     deltaY > 0 | ||||||
|  |                     && comicGridViewModel.coverHeight < comicGridViewModel.maxHeight | ||||||
|  |                     && listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 | ||||||
|  |                     ) { | ||||||
|  |                     val newHeight = (comicGridViewModel.coverHeight + deltaDp).coerceIn(0.dp, comicGridViewModel.maxHeight) | ||||||
|  |                     val consumedDp = newHeight - comicGridViewModel.coverHeight | ||||||
|  |                     comicGridViewModel.coverHeight = newHeight | ||||||
|  |                     val consumedPx = with(dens) { consumedDp.toPx() } | ||||||
|  |                     Offset(0f, consumedPx) | ||||||
|  |                 } else { | ||||||
|  |                     Offset.Zero | ||||||
|  |                 } | ||||||
|  |                 return r | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (comic != null) { | ||||||
|  |         val comic = comic!! | ||||||
|  |         val pagerState = rememberPagerState( | ||||||
|  |             initialPage = 0, | ||||||
|  |             pageCount = { comic.comic.bookmarks.size }) | ||||||
|  |  | ||||||
|  |         Column(Modifier | ||||||
|  |             .nestedScroll(nestedScrollConnection).fillMaxSize()) { | ||||||
|  |             Box(Modifier | ||||||
|  |                 .fillMaxWidth() | ||||||
|  |                 .height(comicGridViewModel.coverHeight)) | ||||||
|  |             { | ||||||
|  |                 HorizontalPager( | ||||||
|  |                     state = pagerState, | ||||||
|  |                     modifier = Modifier.fillMaxSize() | ||||||
|  |                 ) | ||||||
|  |                 { page -> | ||||||
|  |                     AsyncImage( | ||||||
|  |                         model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                             .data(comic.getPage(comic.comic.bookmarks[page].page, comicGridViewModel.apiClient)) | ||||||
|  |                             .memoryCacheKey("${comic.id}/${comic.comic.bookmarks[page].page}") | ||||||
|  |                             .diskCacheKey("${comic.id}/${comic.comic.bookmarks[page].page}") | ||||||
|  |                             .build(), | ||||||
|  |                         contentDescription = null, | ||||||
|  |                         imageLoader = comicGridViewModel.imageLoader!!, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .fillMaxSize(), | ||||||
|  |                         contentScale = ContentScale.FillWidth, | ||||||
|  |                         onSuccess = { success -> | ||||||
|  |                             val drawable = success.result.image | ||||||
|  |                             val width = drawable.width | ||||||
|  |                             val height = drawable.height | ||||||
|  |                             val aspectRatio = width.toFloat() / height.toFloat() | ||||||
|  |                             comicGridViewModel.maxHeight = min(screenWidth / aspectRatio, screenHeight * 0.8f) | ||||||
|  |  | ||||||
|  |                             if(comicGridViewModel.coverHeight > comicGridViewModel.maxHeight) | ||||||
|  |                                 comicGridViewModel.coverHeight = comicGridViewModel.maxHeight | ||||||
|  |                         }, | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 Box(modifier = Modifier | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .align(Alignment.BottomCenter) | ||||||
|  |                         .height(50.dp) | ||||||
|  |                         .background( | ||||||
|  |                             brush = Brush.verticalGradient( | ||||||
|  |                                 colors = listOf( | ||||||
|  |                                     Color.Transparent, | ||||||
|  |                                     Color.Black.copy(alpha = 0.5f), | ||||||
|  |                                 ) | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 BiliMiniSlider( | ||||||
|  |                     value = (pagerState.currentPage + 1) / pagerState.pageCount.toFloat(), | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .height(6.dp) | ||||||
|  |                         .width(100.dp) | ||||||
|  |                         .align(Alignment.BottomCenter) | ||||||
|  |                         .fillMaxWidth(), | ||||||
|  |                     onValueChange = { | ||||||
|  |  | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             LazyColumn( | ||||||
|  |                 state = listState, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxSize() | ||||||
|  |                     .padding(top = 6.dp) | ||||||
|  |                     .clip(RoundedCornerShape(6.dp)) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 item() | ||||||
|  |                 { | ||||||
|  |                     Text( | ||||||
|  |                         text = comic.comic.comic_name, | ||||||
|  |                         fontSize = 18.sp, | ||||||
|  |                         lineHeight = 22.sp, | ||||||
|  |                         fontWeight = FontWeight.Bold, | ||||||
|  |                         maxLines = 1, | ||||||
|  |                         modifier = Modifier.padding(horizontal = 16.dp).padding(top = 16.dp).padding(bottom = 4.dp) | ||||||
|  |                     ) | ||||||
|  |  | ||||||
|  |                     FlowRow( | ||||||
|  |                         modifier =  Modifier.padding(horizontal =  16.dp).padding(bottom = 4.dp) | ||||||
|  |                     ) | ||||||
|  |                     { | ||||||
|  |                         comic.comic.tags.take(15).forEach() | ||||||
|  |                         { | ||||||
|  |                             ic -> | ||||||
|  |                             Card( | ||||||
|  |                                 Modifier.padding(1.dp), | ||||||
|  |                                 shape = RoundedCornerShape(8.dp) | ||||||
|  |                             ) { | ||||||
|  |                                 Text( | ||||||
|  |                                     text = ic, | ||||||
|  |                                     fontSize = 10.sp, | ||||||
|  |                                     lineHeight = 12.sp, | ||||||
|  |                                     fontWeight = FontWeight.Bold, | ||||||
|  |                                     maxLines = 2, | ||||||
|  |                                     modifier = Modifier | ||||||
|  |                                         .padding(4.dp) | ||||||
|  |                                 ) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     Box(Modifier.fillMaxWidth()) | ||||||
|  |                     { | ||||||
|  |                         Text( | ||||||
|  |                             text = "Author: ${comic.comic.author} \n${comic.comic.list.size} Pages", | ||||||
|  |                             fontSize = 11.sp, | ||||||
|  |                             lineHeight = 15.sp, | ||||||
|  |                             maxLines = 3, | ||||||
|  |                             modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 4.dp).align(Alignment.CenterStart) | ||||||
|  |                         ) | ||||||
|  |  | ||||||
|  |                         Button(onClick = { | ||||||
|  |                             comicGridViewModel.updateProcess(comicId.hexToString()) | ||||||
|  |                             { | ||||||
|  |                                 if (record != null) { | ||||||
|  |                                     val route = "comic_page_route/${comic.id.toHex()}/${ | ||||||
|  |                                         record!!.position | ||||||
|  |                                     }" | ||||||
|  |                                     navController.navigate(route) | ||||||
|  |                                 } else { | ||||||
|  |                                     val route = "comic_page_route/${comic.id.toHex()}/${0}" | ||||||
|  |                                     navController.navigate(route) | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         }, modifier = Modifier.align(Alignment.CenterEnd)) | ||||||
|  |                         { | ||||||
|  |                             Text(text = "Continue", fontSize = 16.sp) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     HorizontalDivider(Modifier.padding(horizontal =  12.dp).padding(bottom = 4.dp), thickness = 1.5.dp) | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 items(comicGridViewModel.chapterList) | ||||||
|  |                 { c -> | ||||||
|  |                     ChapterCard(comic, navController, c, comicGridViewModel) | ||||||
|  |                     HorizontalDivider(Modifier.padding(horizontal = 26.dp), thickness = 1.5.dp) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |     /* | ||||||
|  |             Card( | ||||||
|  |                 Modifier | ||||||
|  |                     .padding(horizontal = 16.dp) | ||||||
|  |                     .padding(top = 6.dp) | ||||||
|  |                     .padding(bottom = 20.dp) | ||||||
|  |                     .height(42.dp) | ||||||
|  |                     .clickable { | ||||||
|  |                         comicGridViewModel.updateProcess(comicId.hexToString()) | ||||||
|  |                         { | ||||||
|  |                             if (record != null) { | ||||||
|  |                                 val route = "comic_page_route/${comic.id.toHex()}/${ | ||||||
|  |                                     record!!.position | ||||||
|  |                                 }" | ||||||
|  |                                 navController.navigate(route) | ||||||
|  |                             } else { | ||||||
|  |                                 val route = "comic_page_route/${comic.id.toHex()}/${0}" | ||||||
|  |                                 navController.navigate(route) | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                 colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||||
|  |                 shape = RoundedCornerShape(12.dp) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Box(Modifier.fillMaxSize()) { | ||||||
|  |                     Row( | ||||||
|  |                         Modifier | ||||||
|  |                             .fillMaxWidth() | ||||||
|  |                             .align(Alignment.Center) | ||||||
|  |                             .padding(horizontal = 8.dp) | ||||||
|  |                     ) { | ||||||
|  |                         if (record != null) { | ||||||
|  |                             val k = comic.getPageChapterIndex(record!!.position) | ||||||
|  |  | ||||||
|  |                             Text( | ||||||
|  |                                 text = "Last Read Position: ${k.first.name} ${k.second}/${ | ||||||
|  |                                     comic.getChapterLength( | ||||||
|  |                                         k.first.page | ||||||
|  |                                     ) | ||||||
|  |                                 }", | ||||||
|  |                                 fontSize = 20.sp, | ||||||
|  |                                 fontWeight = FontWeight.Bold, | ||||||
|  |                                 maxLines = 1, | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .padding(4.dp) | ||||||
|  |                                     .weight(1f) | ||||||
|  |                             ) | ||||||
|  |                         } else { | ||||||
|  |                             Text( | ||||||
|  |                                 text = "Read from scratch", | ||||||
|  |                                 fontSize = 20.sp, | ||||||
|  |                                 fontWeight = FontWeight.Bold, | ||||||
|  |                                 maxLines = 1, | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .padding(4.dp) | ||||||
|  |                                     .weight(1f) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |      */ | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun ChapterCard( | ||||||
|  |     comic: Comic, | ||||||
|  |     navController: NavHostController, | ||||||
|  |     chapter: BookMark, | ||||||
|  |     comicGridViewModel: ComicGridViewModel = hiltViewModel<ComicGridViewModel>() | ||||||
|  | ) { | ||||||
|  |     val c = chapter | ||||||
|  |     val iv = comic.getPageIndex(c.page) | ||||||
|  |     val r = comic.comic.list.subList(iv, iv + comic.getChapterLength(c.page)) | ||||||
|  |  | ||||||
|  |     Card( | ||||||
|  |         colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(0.65f)), | ||||||
|  |         shape = RoundedCornerShape(6.dp), | ||||||
|  |         modifier = Modifier | ||||||
|  |             .fillMaxWidth() | ||||||
|  |             .wrapContentHeight() | ||||||
|  |             .padding(horizontal = 16.dp) | ||||||
|  |             .padding(vertical = 6.dp), | ||||||
|  |         onClick = { | ||||||
|  |             val route = "comic_page_route/${comic.id.toHex()}/${comic.getPageIndex(chapter.page)}" | ||||||
|  |             navController.navigate(route) | ||||||
|  |         } | ||||||
|  |     ) { | ||||||
|  |         Column(Modifier.fillMaxWidth()) | ||||||
|  |         { | ||||||
|  |             Text( | ||||||
|  |                 text = chapter.name, | ||||||
|  |                 fontSize = 16.sp, | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 maxLines = 2, | ||||||
|  |                 lineHeight = 18.sp, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(horizontal = 8.dp).padding(vertical = 4.dp) | ||||||
|  |                     .background(Color.Transparent) | ||||||
|  |             ) | ||||||
|  |             Text( | ||||||
|  |                 text = "${comic.getChapterLength(chapter.page)} Pages", | ||||||
|  |                 fontSize = 14.sp, | ||||||
|  |                 lineHeight = 16.sp, | ||||||
|  |                 maxLines = 1, | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .padding(horizontal = 8.dp) | ||||||
|  |                     .background(Color.Transparent) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             LazyRow( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .padding(horizontal = 8.dp).padding(vertical = 4.dp) | ||||||
|  |             ) { | ||||||
|  |                 items(r) | ||||||
|  |                 { r -> | ||||||
|  |                     Card( | ||||||
|  |                         shape = RoundedCornerShape(12.dp), | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .fillMaxWidth() | ||||||
|  |                             .wrapContentHeight() | ||||||
|  |                             .height(120.dp) | ||||||
|  |                             .padding(horizontal = 2.dp), | ||||||
|  |                         onClick = { | ||||||
|  |                             val route = | ||||||
|  |                                 "comic_page_route/${comic.id.toHex()}/${comic.getPageIndex(r)}" | ||||||
|  |                             navController.navigate(route) | ||||||
|  |                         } | ||||||
|  |                     ) { | ||||||
|  |                         AsyncImage( | ||||||
|  |                             model = ImageRequest.Builder(LocalContext.current) | ||||||
|  |                                 .data(comic.getPage(r, comicGridViewModel.apiClient)) | ||||||
|  |                                 .memoryCacheKey("${comic.id}/${r}") | ||||||
|  |                                 .diskCacheKey("${comic.id}/${r}") | ||||||
|  |                                 .build(), | ||||||
|  |                             contentDescription = null, | ||||||
|  |                             imageLoader = comicGridViewModel.imageLoader!!, | ||||||
|  |                             modifier = Modifier | ||||||
|  |                                 .fillMaxSize() | ||||||
|  |                                 .clip(RoundedCornerShape(12.dp)), | ||||||
|  |                             contentScale = ContentScale.Fit, | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,18 +1,22 @@ | |||||||
| package com.acitelight.aether.view | package com.acitelight.aether.view.pages | ||||||
| 
 | 
 | ||||||
|  | import androidx.compose.animation.AnimatedVisibility | ||||||
| import androidx.compose.animation.slideInVertically | import androidx.compose.animation.slideInVertically | ||||||
| import androidx.compose.animation.slideOutVertically | import androidx.compose.animation.slideOutVertically | ||||||
| import androidx.compose.foundation.background | import androidx.compose.foundation.background | ||||||
| import androidx.compose.foundation.clickable | import androidx.compose.foundation.clickable | ||||||
|  | import androidx.compose.foundation.gestures.detectTapGestures | ||||||
| import androidx.compose.foundation.layout.Arrangement | import androidx.compose.foundation.layout.Arrangement | ||||||
| import androidx.compose.foundation.layout.Box | import androidx.compose.foundation.layout.Box | ||||||
| import androidx.compose.foundation.layout.Column | import androidx.compose.foundation.layout.Column | ||||||
| import androidx.compose.foundation.layout.Row | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
| import androidx.compose.foundation.layout.fillMaxHeight | import androidx.compose.foundation.layout.fillMaxHeight | ||||||
| import androidx.compose.foundation.layout.fillMaxSize | import androidx.compose.foundation.layout.fillMaxSize | ||||||
| import androidx.compose.foundation.layout.fillMaxWidth | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
| import androidx.compose.foundation.layout.height | import androidx.compose.foundation.layout.height | ||||||
| import androidx.compose.foundation.layout.padding | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
| import androidx.compose.foundation.layout.widthIn | import androidx.compose.foundation.layout.widthIn | ||||||
| import androidx.compose.foundation.layout.wrapContentHeight | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
| import androidx.compose.foundation.lazy.LazyRow | import androidx.compose.foundation.lazy.LazyRow | ||||||
| @@ -27,6 +31,7 @@ import androidx.compose.material3.Icon | |||||||
| import androidx.compose.material3.MaterialTheme | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.Text | import androidx.compose.material3.Text | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.DisposableEffect | ||||||
| import androidx.compose.runtime.getValue | import androidx.compose.runtime.getValue | ||||||
| import androidx.compose.runtime.mutableStateOf | import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.compose.runtime.remember | import androidx.compose.runtime.remember | ||||||
| @@ -34,9 +39,12 @@ import androidx.compose.runtime.setValue | |||||||
| import androidx.compose.ui.Alignment | import androidx.compose.ui.Alignment | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| import androidx.compose.ui.draw.clip | import androidx.compose.ui.draw.clip | ||||||
|  | import androidx.compose.ui.graphics.Brush | ||||||
| import androidx.compose.ui.graphics.Color | import androidx.compose.ui.graphics.Color | ||||||
|  | import androidx.compose.ui.input.pointer.pointerInput | ||||||
| import androidx.compose.ui.layout.ContentScale | import androidx.compose.ui.layout.ContentScale | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.compose.ui.platform.LocalView | ||||||
| import androidx.compose.ui.text.font.FontWeight | import androidx.compose.ui.text.font.FontWeight | ||||||
| import androidx.compose.ui.unit.dp | import androidx.compose.ui.unit.dp | ||||||
| import androidx.compose.ui.unit.sp | import androidx.compose.ui.unit.sp | ||||||
| @@ -46,6 +54,9 @@ import androidx.navigation.NavHostController | |||||||
| import coil3.compose.AsyncImage | import coil3.compose.AsyncImage | ||||||
| import coil3.request.ImageRequest | import coil3.request.ImageRequest | ||||||
| import com.acitelight.aether.model.BookMark | import com.acitelight.aether.model.BookMark | ||||||
|  | import com.acitelight.aether.setFullScreen | ||||||
|  | import com.acitelight.aether.view.components.BiliMiniSlider | ||||||
|  | import com.acitelight.aether.view.components.BookmarkPop | ||||||
| import com.acitelight.aether.viewModel.ComicPageViewModel | import com.acitelight.aether.viewModel.ComicPageViewModel | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
| 
 | 
 | ||||||
| @@ -69,6 +80,15 @@ fun ComicPageView( | |||||||
|     comicPageViewModel.updateProcess(pagerState.currentPage) |     comicPageViewModel.updateProcess(pagerState.currentPage) | ||||||
| 
 | 
 | ||||||
|     val comic by comicPageViewModel.comic |     val comic by comicPageViewModel.comic | ||||||
|  | 
 | ||||||
|  |     val view = LocalView.current | ||||||
|  |     DisposableEffect(Unit) { | ||||||
|  |         setFullScreen(view, true) | ||||||
|  |         onDispose { | ||||||
|  | 
 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     comic?.let { |     comic?.let { | ||||||
|         Box() |         Box() | ||||||
|         { |         { | ||||||
| @@ -78,7 +98,9 @@ fun ComicPageView( | |||||||
|                     .fillMaxSize() |                     .fillMaxSize() | ||||||
|                     .align(Alignment.Center) |                     .align(Alignment.Center) | ||||||
|                     .background(Color.Black) |                     .background(Color.Black) | ||||||
|                     .clickable { |                     .pointerInput(Unit) { | ||||||
|  |                         detectTapGestures( | ||||||
|  |                             onTap = { | ||||||
|                                 showPlane = !showPlane |                                 showPlane = !showPlane | ||||||
|                                 if (showPlane) { |                                 if (showPlane) { | ||||||
|                                     comicPageViewModel.viewModelScope.launch { |                                     comicPageViewModel.viewModelScope.launch { | ||||||
| @@ -86,10 +108,12 @@ fun ComicPageView( | |||||||
|                                     } |                                     } | ||||||
|                                 } |                                 } | ||||||
|                             } |                             } | ||||||
|  |                         ) | ||||||
|  |                     } | ||||||
|             ) { page -> |             ) { page -> | ||||||
|                 AsyncImage( |                 AsyncImage( | ||||||
|                     model = ImageRequest.Builder(LocalContext.current) |                     model = ImageRequest.Builder(LocalContext.current) | ||||||
|                         .data(it.getPage(page)) |                         .data(it.getPage(page, comicPageViewModel.apiClient)) | ||||||
|                         .memoryCacheKey("${it.id}/${page}") |                         .memoryCacheKey("${it.id}/${page}") | ||||||
|                         .diskCacheKey("${it.id}/${page}") |                         .diskCacheKey("${it.id}/${page}") | ||||||
|                         .build(), |                         .build(), | ||||||
| @@ -102,36 +126,36 @@ fun ComicPageView( | |||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             androidx.compose.animation.AnimatedVisibility( |             AnimatedVisibility( | ||||||
|                 visible = showPlane, |                 visible = showPlane, | ||||||
|                 enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }), |                 enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }), | ||||||
|                 exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }), |                 exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }), | ||||||
|                 modifier = Modifier |                 modifier = Modifier | ||||||
|                     .align(Alignment.TopCenter) |                     .align(Alignment.TopCenter) | ||||||
|             ) { |             ) { | ||||||
|                 Box() |  | ||||||
|                 { |  | ||||||
|                 Column(Modifier |                 Column(Modifier | ||||||
|                     .align(Alignment.TopCenter) |                     .align(Alignment.TopCenter) | ||||||
|                         .fillMaxWidth()) |  | ||||||
|                     { |  | ||||||
|                         Card( |  | ||||||
|                             colors = CardDefaults.cardColors(containerColor = colorScheme.primary), |  | ||||||
|                             shape = RoundedCornerShape(12.dp), |  | ||||||
|                             modifier = Modifier |  | ||||||
|                     .fillMaxWidth() |                     .fillMaxWidth() | ||||||
|                                 .padding(top = 18.dp) |                     .background( | ||||||
|                                 .padding(horizontal = 12.dp) |                         brush = Brush.verticalGradient( | ||||||
|                                 .height(42.dp) |                             colors = listOf( | ||||||
|  |                                 Color.Black.copy(alpha = 0.9f), | ||||||
|  |                                 Color.Transparent, | ||||||
|                             ) |                             ) | ||||||
|  |                         ) | ||||||
|  |                     )) | ||||||
|                 { |                 { | ||||||
|                             Row(modifier = Modifier.fillMaxSize()) |                     Row(modifier = Modifier | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .padding(horizontal =  16.dp).padding(top = 16.dp)) | ||||||
|                     { |                     { | ||||||
|                         Text( |                         Text( | ||||||
|                             text = title, |                             text = title, | ||||||
|                             fontSize = 16.sp, |                             fontSize = 16.sp, | ||||||
|  |                             lineHeight = 19.sp, | ||||||
|                             fontWeight = FontWeight.Bold, |                             fontWeight = FontWeight.Bold, | ||||||
|                             maxLines = 1, |                             maxLines = 1, | ||||||
|  |                             color = Color.White, | ||||||
|                             modifier = Modifier |                             modifier = Modifier | ||||||
|                                 .padding(8.dp) |                                 .padding(8.dp) | ||||||
|                                 .padding(horizontal = 10.dp) |                                 .padding(horizontal = 10.dp) | ||||||
| @@ -141,27 +165,19 @@ fun ComicPageView( | |||||||
| 
 | 
 | ||||||
|                         Text( |                         Text( | ||||||
|                             text = "${pagerState.currentPage + 1}/${pagerState.pageCount}", |                             text = "${pagerState.currentPage + 1}/${pagerState.pageCount}", | ||||||
|                                     fontSize = 18.sp, |                             fontSize = 16.sp, | ||||||
|  |                             lineHeight = 19.sp, | ||||||
|                             fontWeight = FontWeight.Bold, |                             fontWeight = FontWeight.Bold, | ||||||
|                             maxLines = 1, |                             maxLines = 1, | ||||||
|  |                             color = Color.White, | ||||||
|                             modifier = Modifier |                             modifier = Modifier | ||||||
|                                 .padding(8.dp) |                                 .padding(8.dp) | ||||||
|                                 .widthIn(min = 60.dp) |                                 .widthIn(min = 60.dp) | ||||||
|                                 .align(Alignment.CenterVertically) |                                 .align(Alignment.CenterVertically) | ||||||
|                         ) |                         ) | ||||||
|                     } |                     } | ||||||
|                         } |                     Box(Modifier.fillMaxWidth() | ||||||
| 
 |                         .padding(horizontal = 16.dp)) | ||||||
|                         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 { |                         Row { | ||||||
|                             val k = it.getPageChapterIndex(pagerState.currentPage) |                             val k = it.getPageChapterIndex(pagerState.currentPage) | ||||||
| @@ -170,6 +186,7 @@ fun ComicPageView( | |||||||
|                                 fontSize = 16.sp, |                                 fontSize = 16.sp, | ||||||
|                                 fontWeight = FontWeight.Bold, |                                 fontWeight = FontWeight.Bold, | ||||||
|                                 maxLines = 1, |                                 maxLines = 1, | ||||||
|  |                                 color = Color.White, | ||||||
|                                 modifier = Modifier |                                 modifier = Modifier | ||||||
|                                     .padding(8.dp) |                                     .padding(8.dp) | ||||||
|                                     .padding(horizontal = 10.dp) |                                     .padding(horizontal = 10.dp) | ||||||
| @@ -181,13 +198,13 @@ fun ComicPageView( | |||||||
|                                 fontSize = 18.sp, |                                 fontSize = 18.sp, | ||||||
|                                 fontWeight = FontWeight.Bold, |                                 fontWeight = FontWeight.Bold, | ||||||
|                                 maxLines = 1, |                                 maxLines = 1, | ||||||
|  |                                 color = Color.White, | ||||||
|                                 modifier = Modifier |                                 modifier = Modifier | ||||||
|                                     .padding(8.dp) |                                     .padding(8.dp) | ||||||
|                                     .widthIn(min = 60.dp) |                                     .widthIn(min = 60.dp) | ||||||
|                                     .align(Alignment.CenterVertically) |                                     .align(Alignment.CenterVertically) | ||||||
|                             ) |                             ) | ||||||
|                         } |                         } | ||||||
|                             } |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|                         Card( |                         Card( | ||||||
| @@ -196,7 +213,7 @@ fun ComicPageView( | |||||||
|                                 .padding(top = 6.dp) |                                 .padding(top = 6.dp) | ||||||
|                                 .padding(horizontal = 12.dp) |                                 .padding(horizontal = 12.dp) | ||||||
|                                 .height(42.dp), |                                 .height(42.dp), | ||||||
|                                 colors = CardDefaults.cardColors(containerColor = colorScheme.primary), |                             colors = CardDefaults.cardColors(containerColor = colorScheme.surface), | ||||||
|                             shape = RoundedCornerShape(12.dp) |                             shape = RoundedCornerShape(12.dp) | ||||||
|                         ) |                         ) | ||||||
|                         { |                         { | ||||||
| @@ -212,11 +229,11 @@ fun ComicPageView( | |||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                     } |                     Spacer(Modifier.height(64.dp)) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             androidx.compose.animation.AnimatedVisibility( |             AnimatedVisibility( | ||||||
|                 visible = showPlane, |                 visible = showPlane, | ||||||
|                 enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }), |                 enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }), | ||||||
|                 exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }), |                 exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }), | ||||||
| @@ -224,22 +241,31 @@ fun ComicPageView( | |||||||
|                     .align(Alignment.BottomCenter) |                     .align(Alignment.BottomCenter) | ||||||
|             ) |             ) | ||||||
|             { |             { | ||||||
|                 Box { |                 val k = it.getPageChapterIndex(pagerState.currentPage) | ||||||
|  |                 Column(Modifier | ||||||
|  |                     .background( | ||||||
|  |                         brush = Brush.verticalGradient( | ||||||
|  |                             colors = listOf( | ||||||
|  |                                 Color.Transparent, | ||||||
|  |                                 Color.Black.copy(alpha = 0.9f), | ||||||
|  |                             ) | ||||||
|  |                         ) | ||||||
|  |                     )) { | ||||||
|  |                     Spacer(Modifier.height(42.dp)) | ||||||
|                     LazyRow( |                     LazyRow( | ||||||
|                         horizontalArrangement = Arrangement.spacedBy(5.dp), |                         horizontalArrangement = Arrangement.spacedBy(5.dp), | ||||||
|                         state = comicPageViewModel.listState!!, modifier = Modifier |                         state = comicPageViewModel.listState!!, modifier = Modifier | ||||||
|                             .fillMaxWidth() |                             .fillMaxWidth() | ||||||
|                             .padding(bottom = 18.dp) |                             .padding(bottom = 1.dp) | ||||||
|                             .padding(horizontal = 12.dp) |                             .padding(horizontal = 12.dp) | ||||||
|                             .height(240.dp) |                             .height(180.dp) | ||||||
|                             .align(Alignment.BottomCenter) |  | ||||||
|                     ) |                     ) | ||||||
|                     { |                     { | ||||||
|                         items(comicPageViewModel.pageList.size) |                         items(comicPageViewModel.pageList.size) | ||||||
|                         { r -> |                         { r -> | ||||||
|                             Card( |                             Card( | ||||||
|                                 colors = CardDefaults.cardColors(containerColor = colorScheme.primary.copy(0.8f)), |                                 colors = CardDefaults.cardColors(containerColor = colorScheme.primary.copy(0.8f)), | ||||||
|                                 shape = RoundedCornerShape(12.dp), |                                 shape = RoundedCornerShape(8.dp), | ||||||
|                                 modifier = Modifier |                                 modifier = Modifier | ||||||
|                                     .fillMaxHeight() |                                     .fillMaxHeight() | ||||||
|                                     .wrapContentHeight() |                                     .wrapContentHeight() | ||||||
| @@ -248,11 +274,11 @@ fun ComicPageView( | |||||||
|                                     pagerState.requestScrollToPage(page = r) |                                     pagerState.requestScrollToPage(page = r) | ||||||
|                                 } |                                 } | ||||||
|                             ) { |                             ) { | ||||||
|                                 Box(Modifier.padding(4.dp)) |                                 Box(Modifier.padding(0.dp)) | ||||||
|                                 { |                                 { | ||||||
|                                     AsyncImage( |                                     AsyncImage( | ||||||
|                                         model = ImageRequest.Builder(LocalContext.current) |                                         model = ImageRequest.Builder(LocalContext.current) | ||||||
|                                             .data(it.getPage(r)) |                                             .data(it.getPage(r, comicPageViewModel.apiClient)) | ||||||
|                                             .memoryCacheKey("${it.id}/${r}") |                                             .memoryCacheKey("${it.id}/${r}") | ||||||
|                                             .diskCacheKey("${it.id}/${r}") |                                             .diskCacheKey("${it.id}/${r}") | ||||||
|                                             .build(), |                                             .build(), | ||||||
| @@ -260,7 +286,7 @@ fun ComicPageView( | |||||||
|                                         imageLoader = comicPageViewModel.imageLoader!!, |                                         imageLoader = comicPageViewModel.imageLoader!!, | ||||||
|                                         modifier = Modifier |                                         modifier = Modifier | ||||||
|                                             .fillMaxHeight() |                                             .fillMaxHeight() | ||||||
|                                             .clip(RoundedCornerShape(12.dp)) |                                             .clip(RoundedCornerShape(8.dp)) | ||||||
|                                             .align(Alignment.Center), |                                             .align(Alignment.Center), | ||||||
|                                         contentScale = ContentScale.Fit, |                                         contentScale = ContentScale.Fit, | ||||||
|                                     ) |                                     ) | ||||||
| @@ -276,18 +302,6 @@ fun ComicPageView( | |||||||
|                                     ) |                                     ) | ||||||
|                                     { |                                     { | ||||||
|                                         Row { |                                         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( | ||||||
|                                                 text = "${k.second}/${it.getChapterLength(k.first.page)}", |                                                 text = "${k.second}/${it.getChapterLength(k.first.page)}", | ||||||
|                                                 fontSize = 16.sp, |                                                 fontSize = 16.sp, | ||||||
| @@ -304,6 +318,20 @@ fun ComicPageView( | |||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|  | 
 | ||||||
|  |                     BiliMiniSlider( | ||||||
|  |                         value = (k.second.toInt()) / it.getChapterLength(k.first.page).toFloat(), | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .height(6.dp) | ||||||
|  |                             .fillMaxWidth().padding(horizontal = 24.dp) | ||||||
|  |                             .fillMaxWidth(), | ||||||
|  |                         onValueChange = { | ||||||
|  | 
 | ||||||
|  |                         } | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |                     Spacer(Modifier.height(24.dp)) | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
| @@ -0,0 +1,339 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import androidx.compose.animation.AnimatedVisibility | ||||||
|  | import androidx.compose.animation.animateContentSize | ||||||
|  | import androidx.compose.animation.expandVertically | ||||||
|  | import androidx.compose.animation.fadeIn | ||||||
|  | import androidx.compose.animation.fadeOut | ||||||
|  | import androidx.compose.animation.shrinkVertically | ||||||
|  | import androidx.compose.foundation.background | ||||||
|  | import androidx.compose.foundation.clickable | ||||||
|  | import androidx.compose.foundation.layout.Arrangement | ||||||
|  | import androidx.compose.foundation.layout.Box | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.PaddingValues | ||||||
|  | import androidx.compose.foundation.layout.Row | ||||||
|  | import androidx.compose.foundation.layout.Spacer | ||||||
|  | import androidx.compose.foundation.layout.fillMaxSize | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.heightIn | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.layout.size | ||||||
|  | import androidx.compose.foundation.layout.width | ||||||
|  | import androidx.compose.foundation.layout.widthIn | ||||||
|  | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.items | ||||||
|  | import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState | ||||||
|  | import androidx.compose.foundation.rememberScrollState | ||||||
|  | import androidx.compose.foundation.shape.RoundedCornerShape | ||||||
|  | import androidx.compose.foundation.text.BasicTextField | ||||||
|  | import androidx.compose.foundation.verticalScroll | ||||||
|  | import androidx.compose.material.icons.Icons | ||||||
|  | import androidx.compose.material.icons.filled.Search | ||||||
|  | import androidx.compose.material3.Card | ||||||
|  | import androidx.compose.material3.CardDefaults | ||||||
|  | import androidx.compose.material3.ExperimentalMaterial3Api | ||||||
|  | import androidx.compose.material3.ExposedDropdownMenuDefaults | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.Icon | ||||||
|  | import androidx.compose.material3.LocalTextStyle | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.RadioButton | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.runtime.getValue | ||||||
|  | import androidx.compose.runtime.mutableIntStateOf | ||||||
|  | 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.graphics.Color | ||||||
|  | import androidx.compose.ui.layout.Layout | ||||||
|  | import androidx.compose.ui.layout.Placeable | ||||||
|  | import androidx.compose.ui.text.font.FontWeight | ||||||
|  | import androidx.compose.ui.text.style.TextAlign | ||||||
|  | import androidx.compose.ui.unit.Dp | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import com.acitelight.aether.view.components.ComicCard | ||||||
|  | import com.acitelight.aether.viewModel.ComicScreenViewModel | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VariableGrid( | ||||||
|  |     modifier: Modifier = Modifier, | ||||||
|  |     rowHeight: Dp, | ||||||
|  |     horizontalSpacing: Dp = 4.dp, | ||||||
|  |     verticalSpacing: Dp = 4.dp, | ||||||
|  |     content: @Composable () -> Unit | ||||||
|  | ) { | ||||||
|  |     val scrollState = rememberScrollState() | ||||||
|  |  | ||||||
|  |     Layout( | ||||||
|  |         modifier = modifier | ||||||
|  |             .verticalScroll(scrollState), | ||||||
|  |         content = content | ||||||
|  |     ) { measurables, constraints -> | ||||||
|  |  | ||||||
|  |         val rowHeightPx = rowHeight.roundToPx() | ||||||
|  |         val hSpacePx = horizontalSpacing.roundToPx() | ||||||
|  |         val vSpacePx = verticalSpacing.roundToPx() | ||||||
|  |  | ||||||
|  |         val placeables = measurables.map { measurable -> | ||||||
|  |             measurable.measure( | ||||||
|  |                 constraints.copy( | ||||||
|  |                     minWidth = 0, | ||||||
|  |                     minHeight = rowHeightPx, | ||||||
|  |                     maxHeight = rowHeightPx | ||||||
|  |                 ) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val rows = mutableListOf<List<Placeable>>() | ||||||
|  |         var currentRow = mutableListOf<Placeable>() | ||||||
|  |         var currentWidth = 0 | ||||||
|  |         val maxWidth = constraints.maxWidth | ||||||
|  |  | ||||||
|  |         for (placeable in placeables) { | ||||||
|  |             if (currentRow.isNotEmpty() && currentWidth + placeable.width + hSpacePx > maxWidth) { | ||||||
|  |                 rows.add(currentRow) | ||||||
|  |                 currentRow = mutableListOf() | ||||||
|  |                 currentWidth = 0 | ||||||
|  |             } | ||||||
|  |             currentRow.add(placeable) | ||||||
|  |             currentWidth += placeable.width + hSpacePx | ||||||
|  |         } | ||||||
|  |         if (currentRow.isNotEmpty()) { | ||||||
|  |             rows.add(currentRow) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val layoutHeight = if (rows.isEmpty()) { | ||||||
|  |             0 | ||||||
|  |         } else { | ||||||
|  |             rows.size * rowHeightPx + (rows.size - 1) * vSpacePx | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         layout( | ||||||
|  |             width = constraints.maxWidth.coerceAtLeast(constraints.minWidth), | ||||||
|  |             height = layoutHeight.coerceAtLeast(constraints.minHeight) | ||||||
|  |         ) { | ||||||
|  |             var y = 0 | ||||||
|  |             for (row in rows) { | ||||||
|  |                 var x = 0 | ||||||
|  |                 for (placeable in row) { | ||||||
|  |                     placeable.placeRelative(x, y) | ||||||
|  |                     x += placeable.width + hSpacePx | ||||||
|  |                 } | ||||||
|  |                 y += rowHeightPx + vSpacePx | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @OptIn(ExperimentalMaterial3Api::class) | ||||||
|  | @Composable | ||||||
|  | fun ComicScreen( | ||||||
|  |     navController: NavHostController, | ||||||
|  |     comicScreenViewModel: ComicScreenViewModel = hiltViewModel<ComicScreenViewModel>() | ||||||
|  | ) { | ||||||
|  |     val included = comicScreenViewModel.included | ||||||
|  |     val state = rememberLazyStaggeredGridState() | ||||||
|  |     val colorScheme = MaterialTheme.colorScheme | ||||||
|  |     var searchFilter by comicScreenViewModel.searchFilter | ||||||
|  |     var isTagsVisible by remember { mutableStateOf(false) } | ||||||
|  |     var sortType by remember { mutableIntStateOf(0) } | ||||||
|  |  | ||||||
|  |     Column( | ||||||
|  |         modifier = Modifier.animateContentSize() | ||||||
|  |     ) { | ||||||
|  |         Row( | ||||||
|  |             Modifier | ||||||
|  |                 .padding(4.dp) | ||||||
|  |                 .align(Alignment.CenterHorizontally) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             Text( | ||||||
|  |                 text = "Comics", | ||||||
|  |                 style = MaterialTheme.typography.headlineMedium, | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterVertically) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             Spacer(Modifier.weight(1f)) | ||||||
|  |  | ||||||
|  |             Row( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.CenterVertically) | ||||||
|  |                     .height(36.dp) | ||||||
|  |                     .widthIn(max = 240.dp) | ||||||
|  |                     .background(colorScheme.surface, 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) | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         Row { | ||||||
|  |             Text( | ||||||
|  |                 text = "Sorted by: ", | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 fontSize = 16.sp, | ||||||
|  |                 modifier = Modifier.padding(horizontal = 6.dp).align(Alignment.CenterVertically) | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |             RadioButton( | ||||||
|  |                 selected = (sortType == 0), | ||||||
|  |                 onClick = { sortType = 0 }, | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterVertically).size(24.dp) | ||||||
|  |             ) | ||||||
|  |             Text( | ||||||
|  |                 text = "Id", | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 fontSize = 16.sp, | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterVertically).padding(3.dp) | ||||||
|  |             ) | ||||||
|  |             Spacer(modifier = Modifier.width(12.dp)) | ||||||
|  |  | ||||||
|  |             RadioButton( | ||||||
|  |                 selected = (sortType == 1), | ||||||
|  |                 onClick = { sortType = 1 }, | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterVertically).size(24.dp) | ||||||
|  |             ) | ||||||
|  |             Text( | ||||||
|  |                 text = "Name", | ||||||
|  |                 fontWeight = FontWeight.Bold, | ||||||
|  |                 fontSize = 16.sp, | ||||||
|  |                 modifier = Modifier.align(Alignment.CenterVertically).padding(3.dp) | ||||||
|  |             ) | ||||||
|  |             Spacer(modifier = Modifier.width(12.dp)) | ||||||
|  |  | ||||||
|  |             Spacer(Modifier.weight(1f)) | ||||||
|  |  | ||||||
|  |             Card( | ||||||
|  |                 shape = RoundedCornerShape(8.dp), | ||||||
|  |                 colors = CardDefaults.cardColors(containerColor = colorScheme.surface), | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .align(Alignment.CenterVertically) | ||||||
|  |                     .padding(horizontal = 4.dp) | ||||||
|  |                     .padding(vertical = 4.dp) | ||||||
|  |                     .height(32.dp) | ||||||
|  |                     .width(64.dp), | ||||||
|  |                 onClick = { | ||||||
|  |                     isTagsVisible = !isTagsVisible | ||||||
|  |                 }) | ||||||
|  |             { | ||||||
|  |                 Row(Modifier.fillMaxSize()) | ||||||
|  |                 { | ||||||
|  |                     Text(text = "Tags", fontWeight = FontWeight.Bold, fontSize = 16.sp, modifier = Modifier.align(Alignment.CenterVertically).padding(start = 5.dp)) | ||||||
|  |                     ExposedDropdownMenuDefaults.TrailingIcon(expanded = isTagsVisible, modifier = Modifier.align(Alignment.CenterVertically).padding(end = 5.dp)) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         HorizontalDivider(Modifier.padding(1.dp), thickness = 1.5.dp) | ||||||
|  |  | ||||||
|  |         AnimatedVisibility( | ||||||
|  |             visible = isTagsVisible, | ||||||
|  |             enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), | ||||||
|  |             exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut() | ||||||
|  |         ) { | ||||||
|  |             Column { | ||||||
|  |                 VariableGrid( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .heightIn(max = 80.dp) | ||||||
|  |                         .padding(4.dp), | ||||||
|  |                     rowHeight = 30.dp | ||||||
|  |                 ) | ||||||
|  |                 { | ||||||
|  |                     for (i in comicScreenViewModel.tags) { | ||||||
|  |  | ||||||
|  |                         Box( | ||||||
|  |                             Modifier | ||||||
|  |                                 .background( | ||||||
|  |                                     if (included.contains(i)) Color.Green.copy(alpha = 0.65f) else colorScheme.surface, | ||||||
|  |                                     shape = RoundedCornerShape(4.dp) | ||||||
|  |                                 ) | ||||||
|  |                                 .height(32.dp) | ||||||
|  |                                 .widthIn(max = 72.dp) | ||||||
|  |                                 .clickable { | ||||||
|  |                                     if (included.contains(i)) | ||||||
|  |                                         included.remove(i) | ||||||
|  |                                     else | ||||||
|  |                                         included.add(i) | ||||||
|  |                                 } | ||||||
|  |                         ) { | ||||||
|  |                             Text( | ||||||
|  |                                 text = i, | ||||||
|  |                                 fontWeight = FontWeight.Bold, | ||||||
|  |                                 fontSize = 16.sp, | ||||||
|  |                                 maxLines = 1, | ||||||
|  |                                 modifier = Modifier | ||||||
|  |                                     .padding(2.dp) | ||||||
|  |                                     .align(Alignment.Center) | ||||||
|  |                             ) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |  | ||||||
|  |                 HorizontalDivider(Modifier.padding(1.dp), thickness = 1.5.dp) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         LazyVerticalStaggeredGrid( | ||||||
|  |             columns = StaggeredGridCells.Adaptive(120.dp), | ||||||
|  |             contentPadding = PaddingValues(4.dp), | ||||||
|  |             verticalItemSpacing = 6.dp, | ||||||
|  |             horizontalArrangement = Arrangement.spacedBy(4.dp), | ||||||
|  |             state = state, | ||||||
|  |             modifier = Modifier.fillMaxSize() | ||||||
|  |         ) { | ||||||
|  |             items( | ||||||
|  |                 items = comicScreenViewModel.comics | ||||||
|  |                     .filter { searchFilter.isEmpty() || searchFilter in it.comic.comic_name } | ||||||
|  |                     .filter { x -> | ||||||
|  |                         included.all { y -> y in x.comic.tags } || included.isEmpty() | ||||||
|  |                     } | ||||||
|  |                     .sortedByDescending { | ||||||
|  |                         when(sortType) | ||||||
|  |                         { | ||||||
|  |                             0 -> it.id.toInt().toString().padStart(10, '0') | ||||||
|  |                             1 -> it.comic.comic_name | ||||||
|  |                             else -> it.id | ||||||
|  |                         } | ||||||
|  |                     }, | ||||||
|  |                 key = { it.id } | ||||||
|  |             ) { comic -> | ||||||
|  |                 Box( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .fillMaxWidth() | ||||||
|  |                         .wrapContentHeight() | ||||||
|  |                 ) { | ||||||
|  |                     ComicCard(comic, navController, comicScreenViewModel) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,7 +1,6 @@ | |||||||
| package com.acitelight.aether.view | package com.acitelight.aether.view.pages | ||||||
| 
 | 
 | ||||||
| import androidx.compose.foundation.background | import androidx.compose.foundation.background | ||||||
| import androidx.compose.foundation.clickable |  | ||||||
| import androidx.compose.foundation.layout.Arrangement | import androidx.compose.foundation.layout.Arrangement | ||||||
| import androidx.compose.foundation.layout.Box | import androidx.compose.foundation.layout.Box | ||||||
| import androidx.compose.foundation.layout.Column | import androidx.compose.foundation.layout.Column | ||||||
| @@ -37,35 +36,35 @@ import androidx.compose.ui.platform.LocalContext | |||||||
| import androidx.compose.ui.text.font.FontWeight | import androidx.compose.ui.text.font.FontWeight | ||||||
| import androidx.compose.ui.unit.dp | import androidx.compose.ui.unit.dp | ||||||
| import androidx.compose.ui.unit.sp | import androidx.compose.ui.unit.sp | ||||||
| import androidx.navigation.NavController | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
| import androidx.navigation.NavHostController | import androidx.navigation.NavHostController | ||||||
| import coil3.compose.AsyncImage | import coil3.compose.AsyncImage | ||||||
| import coil3.request.ImageRequest | import coil3.request.ImageRequest | ||||||
| import com.acitelight.aether.Global.updateRelate | import com.acitelight.aether.Global.updateRelate | ||||||
| import com.acitelight.aether.model.Comic | import com.acitelight.aether.model.Comic | ||||||
| import com.acitelight.aether.viewModel.ComicScreenViewModel | import com.acitelight.aether.view.components.MiniVideoCard | ||||||
| import com.acitelight.aether.viewModel.HomeScreenViewModel | import com.acitelight.aether.viewModel.HomeScreenViewModel | ||||||
| import kotlinx.coroutines.launch |  | ||||||
| 
 | 
 | ||||||
| @Composable | @Composable | ||||||
| fun HomeScreen( | fun HomeScreen( | ||||||
|     homeScreenViewModel: HomeScreenViewModel = androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel<HomeScreenViewModel>(), |     homeScreenViewModel: HomeScreenViewModel = hiltViewModel<HomeScreenViewModel>(), | ||||||
|     navController: NavHostController) |     navController: NavHostController | ||||||
| { | ) { | ||||||
|     val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 }) |     val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 }) | ||||||
| 
 | 
 | ||||||
|     HorizontalPager( |     HorizontalPager( | ||||||
|         state = pagerState, |         state = pagerState, | ||||||
|         modifier = Modifier.fillMaxSize().background(Color.Black) |         modifier = Modifier | ||||||
|     ){ |             .fillMaxSize() | ||||||
|         p -> |     ) { p -> | ||||||
|         if(p == 0) |         if (p == 0) { | ||||||
|         { |  | ||||||
|             Column(Modifier.fillMaxHeight()) { |             Column(Modifier.fillMaxHeight()) { | ||||||
|                 Text( |                 Text( | ||||||
|                     text = "Videos", |                     text = "Videos", | ||||||
|                     style = MaterialTheme.typography.headlineMedium, |                     style = MaterialTheme.typography.headlineMedium, | ||||||
|                     modifier = Modifier.padding(8.dp).align(Alignment.Start) |                     modifier = Modifier | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .align(Alignment.Start) | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|                 HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) |                 HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) | ||||||
| @@ -73,18 +72,41 @@ fun HomeScreen( | |||||||
|                 LazyColumn(modifier = Modifier.fillMaxWidth()) |                 LazyColumn(modifier = Modifier.fillMaxWidth()) | ||||||
|                 { |                 { | ||||||
|                     items(homeScreenViewModel.recentManager.recentVideo) |                     items(homeScreenViewModel.recentManager.recentVideo) | ||||||
|                     { |                     { i -> | ||||||
|                             i -> |  | ||||||
|                         MiniVideoCard( |                         MiniVideoCard( | ||||||
|                             modifier = Modifier |                             modifier = Modifier | ||||||
|                                 .padding(horizontal = 12.dp), |                                 .padding(horizontal = 12.dp), | ||||||
|                             i, |                             i, | ||||||
|  |                             apiClient = homeScreenViewModel.apiClient, | ||||||
|  |                             imageLoader = homeScreenViewModel.imageLoader!! | ||||||
|  |                         ) | ||||||
|                         { |                         { | ||||||
|                             updateRelate(homeScreenViewModel.recentManager.recentVideo, i) |                             updateRelate(homeScreenViewModel.recentManager.recentVideo, i) | ||||||
|                                 val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }" | 
 | ||||||
|  |                             val playList = mutableListOf<String>() | ||||||
|  |                             val fv = homeScreenViewModel.videoLibrary.classesMap.map { it.value } | ||||||
|  |                                 .flatten() | ||||||
|  | 
 | ||||||
|  |                             val group = | ||||||
|  |                                 fv.filter { it.klass == i.klass && it.video.group == i.video.group && it.video.group != "null" } | ||||||
|  |                             for (ix in group) { | ||||||
|  |                                 playList.add("${ix.klass}/${ix.id}") | ||||||
|  |                             } | ||||||
|  | 
 | ||||||
|  |                             if(!playList.contains("${i.klass}/${i.id}")) | ||||||
|  |                                 playList.add("${i.klass}/${i.id}") | ||||||
|  | 
 | ||||||
|  |                             val route = | ||||||
|  |                                 "video_player_route/${(playList.joinToString(",") + "|${i.id}").toHex()}" | ||||||
|                             navController.navigate(route) |                             navController.navigate(route) | ||||||
|                             }, homeScreenViewModel.imageLoader!!) |                         } | ||||||
|                         HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.4f), 1.dp, DividerDefaults.color) |                         HorizontalDivider( | ||||||
|  |                             Modifier | ||||||
|  |                                 .padding(vertical = 8.dp) | ||||||
|  |                                 .alpha(0.4f), | ||||||
|  |                             1.dp, | ||||||
|  |                             DividerDefaults.color | ||||||
|  |                         ) | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| @@ -93,7 +115,9 @@ fun HomeScreen( | |||||||
|                 Text( |                 Text( | ||||||
|                     text = "Comics", |                     text = "Comics", | ||||||
|                     style = MaterialTheme.typography.headlineMedium, |                     style = MaterialTheme.typography.headlineMedium, | ||||||
|                     modifier = Modifier.padding(8.dp).align(Alignment.Start) |                     modifier = Modifier | ||||||
|  |                         .padding(8.dp) | ||||||
|  |                         .align(Alignment.Start) | ||||||
|                 ) |                 ) | ||||||
| 
 | 
 | ||||||
|                 HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) |                 HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) | ||||||
| @@ -106,8 +130,7 @@ fun HomeScreen( | |||||||
|                 ) |                 ) | ||||||
|                 { |                 { | ||||||
|                     items(homeScreenViewModel.recentManager.recentComic) |                     items(homeScreenViewModel.recentManager.recentComic) | ||||||
|                     { |                     { comic -> | ||||||
|                             comic -> |  | ||||||
|                         ComicCardRecent(comic, navController, homeScreenViewModel) |                         ComicCardRecent(comic, navController, homeScreenViewModel) | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
| @@ -129,7 +152,7 @@ fun ComicCardRecent( | |||||||
|             .fillMaxWidth() |             .fillMaxWidth() | ||||||
|             .wrapContentHeight(), |             .wrapContentHeight(), | ||||||
|         onClick = { |         onClick = { | ||||||
|             val route = "comic_grid_route/${"${comic.id}".toHex()}" |             val route = "comic_grid_route/${comic.id.toHex()}" | ||||||
|             navController.navigate(route) |             navController.navigate(route) | ||||||
|         } |         } | ||||||
|     ) { |     ) { | ||||||
| @@ -140,7 +163,7 @@ fun ComicCardRecent( | |||||||
|             Box(modifier = Modifier.fillMaxSize()) { |             Box(modifier = Modifier.fillMaxSize()) { | ||||||
|                 AsyncImage( |                 AsyncImage( | ||||||
|                     model = ImageRequest.Builder(LocalContext.current) |                     model = ImageRequest.Builder(LocalContext.current) | ||||||
|                         .data(comic.getPage(0)) |                         .data(comic.getPage(0, homeScreenViewModel.apiClient)) | ||||||
|                         .memoryCacheKey("${comic.id}/${0}") |                         .memoryCacheKey("${comic.id}/${0}") | ||||||
|                         .diskCacheKey("${comic.id}/${0}") |                         .diskCacheKey("${comic.id}/${0}") | ||||||
|                         .build(), |                         .build(), | ||||||
| @@ -0,0 +1,13 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import com.acitelight.aether.viewModel.LiveScreenViewModel | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun LiveScreen( | ||||||
|  |     liveScreenViewModel: LiveScreenViewModel = hiltViewModel<LiveScreenViewModel>() | ||||||
|  | ) | ||||||
|  | { | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -1,7 +1,5 @@ | |||||||
| package com.acitelight.aether.view | package com.acitelight.aether.view.pages | ||||||
| 
 | 
 | ||||||
| import android.util.Log |  | ||||||
| import android.widget.Toast |  | ||||||
| import androidx.compose.foundation.layout.Arrangement | import androidx.compose.foundation.layout.Arrangement | ||||||
| import androidx.compose.foundation.layout.Column | import androidx.compose.foundation.layout.Column | ||||||
| import androidx.compose.foundation.layout.Row | import androidx.compose.foundation.layout.Row | ||||||
| @@ -17,7 +15,7 @@ import androidx.compose.material.icons.Icons | |||||||
| import androidx.compose.material.icons.filled.Key | import androidx.compose.material.icons.filled.Key | ||||||
| import androidx.compose.material.icons.filled.Link | import androidx.compose.material.icons.filled.Link | ||||||
| import androidx.compose.material.icons.filled.Person | import androidx.compose.material.icons.filled.Person | ||||||
| import androidx.compose.material.icons.filled.Security | import androidx.compose.material.icons.filled.Textsms | ||||||
| import androidx.compose.material3.Button | import androidx.compose.material3.Button | ||||||
| import androidx.compose.material3.Card | import androidx.compose.material3.Card | ||||||
| import androidx.compose.material3.Checkbox | import androidx.compose.material3.Checkbox | ||||||
| @@ -31,27 +29,20 @@ import androidx.compose.runtime.getValue | |||||||
| import androidx.compose.runtime.setValue | import androidx.compose.runtime.setValue | ||||||
| import androidx.compose.ui.Alignment | import androidx.compose.ui.Alignment | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| import androidx.compose.ui.platform.LocalContext |  | ||||||
| import androidx.compose.ui.text.TextStyle | import androidx.compose.ui.text.TextStyle | ||||||
| import androidx.compose.ui.text.input.KeyboardType | import androidx.compose.ui.text.input.KeyboardType | ||||||
| import androidx.compose.ui.unit.dp | import androidx.compose.ui.unit.dp | ||||||
| import androidx.compose.ui.unit.sp | import androidx.compose.ui.unit.sp | ||||||
| import androidx.hilt.navigation.compose.hiltViewModel | import androidx.hilt.lifecycle.viewmodel.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 com.acitelight.aether.viewModel.MeScreenViewModel | ||||||
| import kotlinx.coroutines.Dispatchers |  | ||||||
| import kotlinx.coroutines.launch |  | ||||||
| import kotlinx.coroutines.withContext |  | ||||||
| 
 | 
 | ||||||
| @Composable | @Composable | ||||||
| fun MeScreen(meScreenViewModel: MeScreenViewModel = androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel<MeScreenViewModel>()) { | fun MeScreen(meScreenViewModel: MeScreenViewModel = hiltViewModel<MeScreenViewModel>()) { | ||||||
|     val context = LocalContext.current |     var username by meScreenViewModel.username | ||||||
|     var username by meScreenViewModel.username; |     var privateKey by meScreenViewModel.privateKey | ||||||
|     var privateKey by meScreenViewModel.privateKey; |  | ||||||
|     var url by meScreenViewModel.url |     var url by meScreenViewModel.url | ||||||
|     var cert by meScreenViewModel.cert |     var cert by meScreenViewModel.cert | ||||||
|  |     var pak by meScreenViewModel.pak | ||||||
| 
 | 
 | ||||||
|     val uss by meScreenViewModel.uss.collectAsState(initial = false) |     val uss by meScreenViewModel.uss.collectAsState(initial = false) | ||||||
| 
 | 
 | ||||||
| @@ -61,7 +52,8 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = androidx.hilt.lifecycle.view | |||||||
|             .padding(8.dp), |             .padding(8.dp), | ||||||
|         horizontalAlignment = Alignment.CenterHorizontally, |         horizontalAlignment = Alignment.CenterHorizontally, | ||||||
|         verticalArrangement = Arrangement.Top |         verticalArrangement = Arrangement.Top | ||||||
|     ) { |     ) | ||||||
|  |     { | ||||||
|         // Card component for a clean, contained UI block |         // Card component for a clean, contained UI block | ||||||
|         item{ |         item{ | ||||||
|             Card( |             Card( | ||||||
| @@ -204,18 +196,53 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = androidx.hilt.lifecycle.view | |||||||
|                         ) { |                         ) { | ||||||
|                             Text("Save") |                             Text("Save") | ||||||
|                         } |                         } | ||||||
|  |                     } | ||||||
|  |                 } | ||||||
|  |             } | ||||||
| 
 | 
 | ||||||
|  |             Card( | ||||||
|  |                 modifier = Modifier | ||||||
|  |                     .fillMaxWidth() | ||||||
|  |                     .padding(8.dp) | ||||||
|  |             ) | ||||||
|  |             { | ||||||
|  |                 Column( | ||||||
|  |                     modifier = Modifier | ||||||
|  |                         .padding(16.dp) | ||||||
|  |                         .fillMaxWidth(), | ||||||
|  |                     horizontalAlignment = Alignment.CenterHorizontally | ||||||
|  |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         text = "Toolbox", | ||||||
|  |                         style = MaterialTheme.typography.headlineMedium, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(bottom = 16.dp) | ||||||
|  |                             .align(Alignment.Start) | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                     Spacer(modifier = Modifier.width(8.dp)) | ||||||
|  | 
 | ||||||
|  |                     OutlinedTextField( | ||||||
|  |                         value = pak, | ||||||
|  |                         onValueChange = { pak = it }, | ||||||
|  |                         label = { Text("Packet") }, | ||||||
|  |                         leadingIcon = { | ||||||
|  |                             Icon(Icons.Default.Textsms, contentDescription = "Packet") | ||||||
|  |                         }, | ||||||
|  |                         singleLine = true, | ||||||
|  |                         modifier = Modifier.fillMaxWidth() | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|  |                     Spacer(modifier = Modifier.height(8.dp)) | ||||||
|  | 
 | ||||||
|  |                     Row{ | ||||||
|                         Button( |                         Button( | ||||||
|                             onClick = { |                             onClick = { | ||||||
|                                 meScreenViewModel.viewModelScope.launch { |                                 meScreenViewModel.sendPacket(pak) | ||||||
|                                     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) |                             modifier = Modifier.weight(0.5f).padding(8.dp) | ||||||
|                         ) { |                         ) { | ||||||
|                             Text("Ping") |                             Text("Send") | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
| @@ -0,0 +1,138 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import androidx.compose.foundation.layout.Column | ||||||
|  | import androidx.compose.foundation.layout.fillMaxWidth | ||||||
|  | import androidx.compose.foundation.layout.height | ||||||
|  | import androidx.compose.foundation.layout.padding | ||||||
|  | import androidx.compose.foundation.lazy.LazyColumn | ||||||
|  | import androidx.compose.foundation.lazy.items | ||||||
|  | import androidx.compose.material3.DividerDefaults | ||||||
|  | import androidx.compose.material3.HorizontalDivider | ||||||
|  | import androidx.compose.material3.MaterialTheme | ||||||
|  | import androidx.compose.material3.Text | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.Alignment | ||||||
|  | import androidx.compose.ui.Modifier | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
|  | import androidx.compose.ui.unit.sp | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import com.acitelight.aether.model.VideoDownloadItemState | ||||||
|  | import com.acitelight.aether.view.components.BiliMiniSlider | ||||||
|  | import com.acitelight.aether.view.components.VideoDownloadCardMini | ||||||
|  | import com.acitelight.aether.viewModel.TransmissionScreenViewModel | ||||||
|  | import com.tonyodev.fetch2.Status | ||||||
|  | import kotlin.collections.sortedWith | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun TransmissionScreen( | ||||||
|  |     navigator: NavHostController, | ||||||
|  |     transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel<TransmissionScreenViewModel>() | ||||||
|  | ) { | ||||||
|  |     val downloads = transmissionScreenViewModel.downloads | ||||||
|  |     Column() | ||||||
|  |     { | ||||||
|  |         Text( | ||||||
|  |             text = "Video Tasks", | ||||||
|  |             style = MaterialTheme.typography.headlineMedium, | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .padding(8.dp) | ||||||
|  |                 .align(Alignment.Start) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         Text( | ||||||
|  |             text = "All: ${downloads.count { it.type == "main" }}", | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .padding(horizontal = 8.dp) | ||||||
|  |                 .align(Alignment.Start), | ||||||
|  |             fontSize = 12.sp, | ||||||
|  |             lineHeight = 13.sp, | ||||||
|  |             maxLines = 1 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         Text( | ||||||
|  |             text = "Completed: ${downloads.count { it.type == "main" && it.status == Status.COMPLETED }}", | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .padding(horizontal = 8.dp) | ||||||
|  |                 .align(Alignment.Start), | ||||||
|  |             fontSize = 12.sp, | ||||||
|  |             lineHeight = 13.sp, | ||||||
|  |             maxLines = 1 | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         val downloading = downloads.filter { it.status == Status.DOWNLOADING } | ||||||
|  |         BiliMiniSlider( | ||||||
|  |             value = if (downloading.sumOf { it.totalBytes } == 0L) 1f else downloading.sumOf { it.downloadedBytes } / downloading.sumOf { it.totalBytes } | ||||||
|  |                 .toFloat(), | ||||||
|  |             modifier = Modifier | ||||||
|  |                 .height(6.dp) | ||||||
|  |                 .align(Alignment.End) | ||||||
|  |                 .fillMaxWidth(), | ||||||
|  |             onValueChange = { | ||||||
|  |  | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) | ||||||
|  |  | ||||||
|  |         LazyColumn( | ||||||
|  |             modifier = Modifier.fillMaxWidth() | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             items( | ||||||
|  |                 downloads | ||||||
|  |                 .filter { it.type == "main" } | ||||||
|  |                 .sortedWith(compareBy(naturalOrder()) { it.fileName }) | ||||||
|  |                 .sortedBy { it.status == Status.COMPLETED }, key = { it.id }) | ||||||
|  |             { item -> | ||||||
|  |                 VideoDownloadCardMini( | ||||||
|  |                     navigator = navigator, | ||||||
|  |                     viewModel = transmissionScreenViewModel, | ||||||
|  |                     model = item, | ||||||
|  |                     onPause = { | ||||||
|  |                         for (i in downloadToGroup( | ||||||
|  |                             item, | ||||||
|  |                             downloads | ||||||
|  |                         )) transmissionScreenViewModel.pause(i.id) | ||||||
|  |                     }, | ||||||
|  |                     onResume = { | ||||||
|  |                         for (i in downloadToGroup( | ||||||
|  |                             item, | ||||||
|  |                             downloads | ||||||
|  |                         )) transmissionScreenViewModel.resume(i.id) | ||||||
|  |                     }, | ||||||
|  |                     onCancel = { | ||||||
|  |                         for (i in downloadToGroup( | ||||||
|  |                             item, | ||||||
|  |                             downloads | ||||||
|  |                         )) transmissionScreenViewModel.delete(i.id) | ||||||
|  |                     }, | ||||||
|  |                     onDelete = { | ||||||
|  |                         for (i in downloadToGroup( | ||||||
|  |                             item, | ||||||
|  |                             downloads | ||||||
|  |                         )) transmissionScreenViewModel.delete(i.id) | ||||||
|  |                     }, | ||||||
|  |                     onRetry = { | ||||||
|  |                         for (i in downloadToGroup( | ||||||
|  |                             item, | ||||||
|  |                             downloads | ||||||
|  |                         )) transmissionScreenViewModel.retry(i.id) | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |                 HorizontalDivider( | ||||||
|  |                     Modifier.padding(horizontal = 16.dp, vertical = 6.dp), | ||||||
|  |                     2.dp, | ||||||
|  |                     DividerDefaults.color | ||||||
|  |                 ) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fun downloadToGroup( | ||||||
|  |     i: VideoDownloadItemState, | ||||||
|  |     downloads: List<VideoDownloadItemState> | ||||||
|  | ): List<VideoDownloadItemState> { | ||||||
|  |     return downloads.filter { it.vid == i.vid && it.klass == i.klass } | ||||||
|  | } | ||||||
| @@ -0,0 +1,68 @@ | |||||||
|  | package com.acitelight.aether.view.pages | ||||||
|  |  | ||||||
|  | import android.app.Activity | ||||||
|  | import android.content.pm.ActivityInfo | ||||||
|  | import androidx.compose.runtime.Composable | ||||||
|  | import androidx.compose.ui.platform.LocalContext | ||||||
|  | import androidx.navigation.NavHostController | ||||||
|  | import com.acitelight.aether.viewModel.VideoPlayerViewModel | ||||||
|  |  | ||||||
|  | import androidx.compose.runtime.DisposableEffect | ||||||
|  | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
|  | import com.acitelight.aether.view.components.VideoPlayerLandscape | ||||||
|  | import com.acitelight.aether.view.components.VideoPlayerPortal | ||||||
|  | import kotlin.math.pow | ||||||
|  |  | ||||||
|  | fun formatTime(ms: Long): String { | ||||||
|  |     if (ms <= 0) return "00:00:00" | ||||||
|  |     val totalSeconds = ms / 1000 | ||||||
|  |     val hours = totalSeconds / 3600 | ||||||
|  |     val minutes = (totalSeconds % 3600) / 60 | ||||||
|  |     val seconds = totalSeconds % 60 | ||||||
|  |     return String.format("%02d:%02d:%02d", hours, minutes, seconds) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | fun moveBrit(db: Float, activity: Activity, videoPlayerViewModel: VideoPlayerViewModel) { | ||||||
|  |     val attr = activity.window.attributes | ||||||
|  |  | ||||||
|  |     val britUi = (videoPlayerViewModel.brit - db * 0.002f).coerceIn(0f, 1f) | ||||||
|  |     videoPlayerViewModel.brit = britUi | ||||||
|  |  | ||||||
|  |     val gamma = 2.2f | ||||||
|  |     val britSystem = britUi.pow(gamma).coerceIn(0.001f, 1f) | ||||||
|  |  | ||||||
|  |     attr.screenBrightness = britSystem | ||||||
|  |     activity.window.attributes = attr | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Composable | ||||||
|  | fun VideoPlayer( | ||||||
|  |     videoPlayerViewModel: VideoPlayerViewModel = hiltViewModel<VideoPlayerViewModel>(), | ||||||
|  |     videoId: String, | ||||||
|  |     navController: NavHostController | ||||||
|  | ) { | ||||||
|  |     val context = LocalContext.current | ||||||
|  |     val activity = (context as? Activity)!! | ||||||
|  |  | ||||||
|  |     DisposableEffect(Unit) { | ||||||
|  |         onDispose { | ||||||
|  |             activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     videoPlayerViewModel.init(videoId) | ||||||
|  |  | ||||||
|  |     activity.requestedOrientation = | ||||||
|  |         if(videoPlayerViewModel.isLandscape) | ||||||
|  |             ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE | ||||||
|  |         else | ||||||
|  |             ActivityInfo.SCREEN_ORIENTATION_PORTRAIT | ||||||
|  |  | ||||||
|  |     if (videoPlayerViewModel.startPlaying) { | ||||||
|  |         if (videoPlayerViewModel.isLandscape) { | ||||||
|  |             VideoPlayerLandscape(videoPlayerViewModel) | ||||||
|  |         } else { | ||||||
|  |             VideoPlayerPortal(videoPlayerViewModel, navController) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -1,11 +1,9 @@ | |||||||
| package com.acitelight.aether.view | package com.acitelight.aether.view.pages | ||||||
| 
 | 
 | ||||||
| import android.widget.Toast | import android.widget.Toast | ||||||
| import androidx.compose.animation.AnimatedVisibility | import androidx.compose.animation.AnimatedVisibility | ||||||
| import androidx.compose.animation.slideInHorizontally | import androidx.compose.animation.slideInHorizontally | ||||||
| import androidx.compose.animation.slideInVertically |  | ||||||
| import androidx.compose.animation.slideOutHorizontally | import androidx.compose.animation.slideOutHorizontally | ||||||
| import androidx.compose.animation.slideOutVertically |  | ||||||
| import androidx.compose.foundation.background | import androidx.compose.foundation.background | ||||||
| import androidx.compose.foundation.clickable | import androidx.compose.foundation.clickable | ||||||
| import androidx.compose.foundation.combinedClickable | import androidx.compose.foundation.combinedClickable | ||||||
| @@ -26,9 +24,6 @@ import androidx.compose.foundation.layout.width | |||||||
| import androidx.compose.foundation.layout.widthIn | import androidx.compose.foundation.layout.widthIn | ||||||
| import androidx.compose.foundation.layout.wrapContentHeight | import androidx.compose.foundation.layout.wrapContentHeight | ||||||
| import androidx.compose.foundation.lazy.LazyColumn | import androidx.compose.foundation.lazy.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.items | ||||||
| import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid | import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid | ||||||
| import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells | import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells | ||||||
| @@ -39,20 +34,15 @@ import androidx.compose.foundation.text.BasicTextField | |||||||
| import androidx.compose.material.icons.Icons | import androidx.compose.material.icons.Icons | ||||||
| import androidx.compose.material.icons.filled.Menu | import androidx.compose.material.icons.filled.Menu | ||||||
| import androidx.compose.material.icons.filled.Search | import androidx.compose.material.icons.filled.Search | ||||||
| import androidx.compose.material3.Button |  | ||||||
| import androidx.compose.material3.Card | import androidx.compose.material3.Card | ||||||
| import androidx.compose.material3.CardDefaults | import androidx.compose.material3.CardDefaults | ||||||
| import androidx.compose.material3.CheckboxDefaults.colors |  | ||||||
| import androidx.compose.material3.DividerDefaults | import androidx.compose.material3.DividerDefaults | ||||||
| import androidx.compose.material3.ExperimentalMaterial3Api |  | ||||||
| import androidx.compose.material3.HorizontalDivider | import androidx.compose.material3.HorizontalDivider | ||||||
| import androidx.compose.material3.Icon | import androidx.compose.material3.Icon | ||||||
| import androidx.compose.material3.LocalTextStyle | import androidx.compose.material3.LocalTextStyle | ||||||
| import androidx.compose.material3.MaterialTheme | import androidx.compose.material3.MaterialTheme | ||||||
| import androidx.compose.material3.Tab |  | ||||||
| import androidx.compose.material3.Text | import androidx.compose.material3.Text | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.Composable | ||||||
| import androidx.compose.runtime.collectAsState |  | ||||||
| import androidx.compose.runtime.getValue | import androidx.compose.runtime.getValue | ||||||
| import androidx.compose.ui.Modifier | import androidx.compose.ui.Modifier | ||||||
| import androidx.compose.ui.graphics.Color | import androidx.compose.ui.graphics.Color | ||||||
| @@ -60,38 +50,31 @@ import androidx.compose.ui.layout.ContentScale | |||||||
| import androidx.compose.ui.text.font.FontWeight | import androidx.compose.ui.text.font.FontWeight | ||||||
| import androidx.compose.ui.unit.dp | import androidx.compose.ui.unit.dp | ||||||
| import androidx.compose.ui.unit.sp | import androidx.compose.ui.unit.sp | ||||||
| import androidx.lifecycle.viewmodel.compose.viewModel |  | ||||||
| import coil3.compose.AsyncImage | import coil3.compose.AsyncImage | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import com.acitelight.aether.viewModel.VideoScreenViewModel | import com.acitelight.aether.viewModel.VideoScreenViewModel | ||||||
| import androidx.compose.material3.ScrollableTabRow |  | ||||||
| import androidx.compose.material3.Surface |  | ||||||
| import androidx.compose.material3.TextField |  | ||||||
| import androidx.compose.material3.TextFieldDefaults |  | ||||||
| import androidx.compose.runtime.mutableStateListOf | import androidx.compose.runtime.mutableStateListOf | ||||||
| import androidx.compose.runtime.mutableStateOf |  | ||||||
| import androidx.compose.runtime.setValue | import androidx.compose.runtime.setValue | ||||||
| import androidx.compose.ui.Alignment | import androidx.compose.ui.Alignment | ||||||
| import androidx.compose.ui.graphics.Brush | import androidx.compose.ui.graphics.Brush | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.compose.ui.platform.LocalContext | ||||||
| import androidx.compose.ui.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.text.style.TextAlign | ||||||
| import androidx.compose.ui.unit.min |  | ||||||
| import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import androidx.navigation.NavHostController | import androidx.navigation.NavHostController | ||||||
| import coil3.request.ImageRequest | import coil3.request.ImageRequest | ||||||
| import com.acitelight.aether.CardPage | import com.acitelight.aether.CardPage | ||||||
| import com.acitelight.aether.Global |  | ||||||
| import com.acitelight.aether.Global.updateRelate | import com.acitelight.aether.Global.updateRelate | ||||||
| import kotlinx.coroutines.Dispatchers | import com.acitelight.aether.view.components.VideoCard | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
| import kotlinx.coroutines.withContext |  | ||||||
| import java.nio.charset.Charset | import java.nio.charset.Charset | ||||||
| import java.security.KeyPair | import kotlin.collections.sortedWith | ||||||
|  | 
 | ||||||
|  | fun videoToView(v: List<Video>): Map<String?, List<Video>> | ||||||
|  | { | ||||||
|  |     return v.map { if(it.video.group != null) it else Video(id=it.id, isLocal = it.isLocal, localBase = it.localBase, | ||||||
|  |         klass = it.klass, video = it.video.copy(group = it.video.name)) }.groupBy { it.video.group } | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| fun String.toHex(): String { | fun String.toHex(): String { | ||||||
|     return this.toByteArray().joinToString("") { "%02x".format(it) } |     return this.toByteArray().joinToString("") { "%02x".format(it) } | ||||||
| @@ -119,6 +102,13 @@ fun VideoScreen( | |||||||
|     var menuVisibility by videoScreenViewModel.menuVisibility |     var menuVisibility by videoScreenViewModel.menuVisibility | ||||||
|     var searchFilter by videoScreenViewModel.searchFilter |     var searchFilter by videoScreenViewModel.searchFilter | ||||||
|     var doneInit by videoScreenViewModel.doneInit |     var doneInit by videoScreenViewModel.doneInit | ||||||
|  |     val vb = videoToView(videoScreenViewModel.videoLibrary.classesMap.getOrDefault( | ||||||
|  |         videoScreenViewModel.videoLibrary.classes.getOrNull( | ||||||
|  |             tabIndex | ||||||
|  |         ), listOf() | ||||||
|  |     ).filter { it.video.name.contains(searchFilter) }).filter { it.key != null } | ||||||
|  |         .map{ i -> Pair(i.key!!, i.value.sortedWith(compareBy(naturalOrder()) { it.video.name }) ) } | ||||||
|  |         .toList() | ||||||
| 
 | 
 | ||||||
|     if (doneInit) |     if (doneInit) | ||||||
|         CardPage(title = "Videos") { |         CardPage(title = "Videos") { | ||||||
| @@ -127,15 +117,23 @@ fun VideoScreen( | |||||||
|                 Column( |                 Column( | ||||||
|                     modifier = Modifier.fillMaxSize() |                     modifier = Modifier.fillMaxSize() | ||||||
|                 ) { |                 ) { | ||||||
|  |                     Text( | ||||||
|  |                         text = "Videos", | ||||||
|  |                         style = MaterialTheme.typography.headlineMedium, | ||||||
|  |                         modifier = Modifier | ||||||
|  |                             .padding(horizontal =  8.dp) | ||||||
|  |                             .align(Alignment.Start) | ||||||
|  |                     ) | ||||||
|  | 
 | ||||||
|                     // TopRow(videoScreenViewModel); |                     // TopRow(videoScreenViewModel); | ||||||
|                     Row(Modifier.padding(bottom = 4.dp)) |                     Row(Modifier.padding(bottom = 4.dp).padding(start = 8.dp)) | ||||||
|                     { |                     { | ||||||
|                         Card( |                         Card( | ||||||
|                             shape = RoundedCornerShape(8.dp), |                             shape = RoundedCornerShape(8.dp), | ||||||
|                             colors = CardDefaults.cardColors(containerColor = colorScheme.primary), |                             colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||||
|                             modifier = Modifier |                             modifier = Modifier | ||||||
|                                 .align(Alignment.CenterVertically) |                                 .align(Alignment.CenterVertically) | ||||||
|                                 .padding(horizontal = 2.dp) |                                 .padding(horizontal = 1.dp) | ||||||
|                                 .size(36.dp), |                                 .size(36.dp), | ||||||
|                             onClick = { |                             onClick = { | ||||||
|                                 menuVisibility = !menuVisibility |                                 menuVisibility = !menuVisibility | ||||||
| @@ -158,7 +156,7 @@ fun VideoScreen( | |||||||
|                             colors = CardDefaults.cardColors(containerColor = colorScheme.primary), |                             colors = CardDefaults.cardColors(containerColor = colorScheme.primary), | ||||||
|                             modifier = Modifier |                             modifier = Modifier | ||||||
|                                 .align(Alignment.CenterVertically) |                                 .align(Alignment.CenterVertically) | ||||||
|                                 .padding(horizontal = 2.dp) |                                 .padding(horizontal = 1.dp) | ||||||
|                                 .height(36.dp), |                                 .height(36.dp), | ||||||
|                             onClick = { |                             onClick = { | ||||||
|                                 menuVisibility = !menuVisibility |                                 menuVisibility = !menuVisibility | ||||||
| @@ -210,34 +208,31 @@ fun VideoScreen( | |||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                     HorizontalDivider( |                     HorizontalDivider( | ||||||
|                         Modifier.padding(bottom = 8.dp), |                         Modifier.padding(4.dp), | ||||||
|                         1.5.dp, |                         2.dp, | ||||||
|                         DividerDefaults.color |                         DividerDefaults.color | ||||||
|                     ) |                     ) | ||||||
|                     LazyVerticalStaggeredGrid( |                     LazyVerticalStaggeredGrid( | ||||||
|                         columns = StaggeredGridCells.Adaptive(160.dp), |                         columns = StaggeredGridCells.Adaptive(160.dp), | ||||||
|                         contentPadding = PaddingValues(8.dp), |                         contentPadding = PaddingValues(8.dp), | ||||||
|                         verticalItemSpacing = 8.dp, |                         verticalItemSpacing = 8.dp, | ||||||
|                         horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy( |                         horizontalArrangement = Arrangement.spacedBy( | ||||||
|                             8.dp |                             8.dp | ||||||
|                         ), |                         ), | ||||||
|                         state = state, |                         state = state, | ||||||
|                         modifier = Modifier.fillMaxSize() |                         modifier = Modifier.fillMaxSize() | ||||||
|                     ) { |                     ) { | ||||||
|                         items( |                         items( | ||||||
|                             items = videoScreenViewModel.videoLibrary.classesMap.getOrDefault( |                             items = vb, | ||||||
|                                 videoScreenViewModel.videoLibrary.classes.getOrNull( |                             key = { "${it.first}/${it.second}" } | ||||||
|                                     tabIndex |  | ||||||
|                                 ), listOf() |  | ||||||
|                             ).filter { it.video.name.contains(searchFilter) }, |  | ||||||
|                             key = { "${it.klass}/${it.id}" } |  | ||||||
|                         ) { video -> |                         ) { video -> | ||||||
|                             androidx.compose.foundation.layout.Box( |                             Box( | ||||||
|                                 modifier = Modifier |                                 modifier = Modifier | ||||||
|                                     .fillMaxWidth() |                                     .fillMaxWidth() | ||||||
|                                     .wrapContentHeight() |                                     .wrapContentHeight() | ||||||
|                             ) { |                             ) { | ||||||
|                                 VideoCard(video, navController, videoScreenViewModel) |                                 if(video.second.isNotEmpty()) | ||||||
|  |                                     VideoCard(video.second, navController, videoScreenViewModel) | ||||||
|                             } |                             } | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
| @@ -309,128 +304,3 @@ fun CatalogueItemRow( | |||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 |  | ||||||
| @Composable |  | ||||||
| fun VideoCard( |  | ||||||
|     video: Video, |  | ||||||
|     navController: NavHostController, |  | ||||||
|     videoScreenViewModel: VideoScreenViewModel |  | ||||||
| ) { |  | ||||||
|     val tabIndex by videoScreenViewModel.tabIndex; |  | ||||||
|     Card( |  | ||||||
|         modifier = Modifier |  | ||||||
|             .fillMaxWidth() |  | ||||||
|             .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()) { |  | ||||||
| 
 |  | ||||||
|                 AsyncImage( |  | ||||||
|                     model = ImageRequest.Builder(LocalContext.current) |  | ||||||
|                         .data(video.getCover()) |  | ||||||
|                         .memoryCacheKey("${video.klass}/${video.id}/cover") |  | ||||||
|                         .diskCacheKey("${video.klass}/${video.id}/cover") |  | ||||||
|                         .build(), |  | ||||||
|                     contentDescription = null, |  | ||||||
|                     modifier = Modifier |  | ||||||
|                         .fillMaxSize(), |  | ||||||
|                     contentScale = ContentScale.Fit, |  | ||||||
|                     imageLoader = videoScreenViewModel.imageLoader!! |  | ||||||
|                 ) |  | ||||||
| 
 |  | ||||||
|                 Text( |  | ||||||
|                     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) |  | ||||||
|                                 ) |  | ||||||
|                             ) |  | ||||||
|                         ) |  | ||||||
|                         .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 = 12.sp, |  | ||||||
|                 fontWeight = FontWeight.Bold, |  | ||||||
|                 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 = 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) |  | ||||||
|             } |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| } |  | ||||||
| @@ -1,11 +1,12 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
| import android.content.Context | import android.content.Context | ||||||
| import androidx.compose.runtime.Composable | import androidx.compose.runtime.getValue | ||||||
| import androidx.compose.runtime.mutableStateListOf | import androidx.compose.runtime.mutableStateListOf | ||||||
| import androidx.compose.runtime.mutableStateOf | import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.compose.runtime.remember | import androidx.compose.runtime.remember | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.compose.runtime.setValue | ||||||
|  | import androidx.compose.ui.unit.dp | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import coil3.ImageLoader | import coil3.ImageLoader | ||||||
| @@ -14,7 +15,7 @@ import com.acitelight.aether.model.BookMark | |||||||
| import com.acitelight.aether.model.Comic | import com.acitelight.aether.model.Comic | ||||||
| import com.acitelight.aether.model.ComicRecord | import com.acitelight.aether.model.ComicRecord | ||||||
| import com.acitelight.aether.model.ComicRecordDatabase | import com.acitelight.aether.model.ComicRecordDatabase | ||||||
| import com.acitelight.aether.service.ApiClient.createOkHttp | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import com.acitelight.aether.service.RecentManager | import com.acitelight.aether.service.RecentManager | ||||||
| import dagger.hilt.android.lifecycle.HiltViewModel | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
| @@ -26,9 +27,13 @@ import javax.inject.Inject | |||||||
| class ComicGridViewModel @Inject constructor( | class ComicGridViewModel @Inject constructor( | ||||||
|     @ApplicationContext val context: Context, |     @ApplicationContext val context: Context, | ||||||
|     val mediaManager: MediaManager, |     val mediaManager: MediaManager, | ||||||
|     val recentManager: RecentManager |     val recentManager: RecentManager, | ||||||
|  |     val apiClient: ApiClient | ||||||
| )  : ViewModel() | )  : ViewModel() | ||||||
| { | { | ||||||
|  |     var coverHeight by mutableStateOf(220.dp) | ||||||
|  |     var maxHeight = 0.dp | ||||||
|  |  | ||||||
|     var imageLoader: ImageLoader? = null |     var imageLoader: ImageLoader? = null | ||||||
|     var comic = mutableStateOf<Comic?>(null) |     var comic = mutableStateOf<Comic?>(null) | ||||||
|     val chapterList = mutableStateListOf<BookMark>() |     val chapterList = mutableStateListOf<BookMark>() | ||||||
| @@ -38,7 +43,7 @@ class ComicGridViewModel @Inject constructor( | |||||||
|     init { |     init { | ||||||
|         imageLoader =  ImageLoader.Builder(context) |         imageLoader =  ImageLoader.Builder(context) | ||||||
|             .components { |             .components { | ||||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|             } |             } | ||||||
|             .build() |             .build() | ||||||
|         db = try{ |         db = try{ | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory | |||||||
| import com.acitelight.aether.model.Comic | import com.acitelight.aether.model.Comic | ||||||
| import com.acitelight.aether.model.ComicRecord | import com.acitelight.aether.model.ComicRecord | ||||||
| import com.acitelight.aether.model.ComicRecordDatabase | import com.acitelight.aether.model.ComicRecordDatabase | ||||||
| import com.acitelight.aether.service.ApiClient.createOkHttp | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import dagger.hilt.android.lifecycle.HiltViewModel | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
| import dagger.hilt.android.qualifiers.ApplicationContext | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| @@ -28,7 +28,8 @@ import javax.inject.Inject | |||||||
| @HiltViewModel | @HiltViewModel | ||||||
| class ComicPageViewModel @Inject constructor( | class ComicPageViewModel @Inject constructor( | ||||||
|     val mediaManager: MediaManager, |     val mediaManager: MediaManager, | ||||||
|     @ApplicationContext private val context: Context |     @ApplicationContext private val context: Context, | ||||||
|  |     val apiClient: ApiClient | ||||||
| ) : ViewModel() | ) : ViewModel() | ||||||
| { | { | ||||||
|     var imageLoader: ImageLoader? = null |     var imageLoader: ImageLoader? = null | ||||||
| @@ -43,7 +44,7 @@ class ComicPageViewModel @Inject constructor( | |||||||
|     init{ |     init{ | ||||||
|         imageLoader =  ImageLoader.Builder(context) |         imageLoader =  ImageLoader.Builder(context) | ||||||
|             .components { |             .components { | ||||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|             } |             } | ||||||
|             .build() |             .build() | ||||||
|         listState = LazyListState(0, 0) |         listState = LazyListState(0, 0) | ||||||
|   | |||||||
| @@ -1,32 +1,30 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
| import android.content.Context | import android.content.Context | ||||||
| import androidx.compose.runtime.Composable |  | ||||||
| import androidx.compose.runtime.mutableStateListOf | import androidx.compose.runtime.mutableStateListOf | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import coil3.ImageLoader | import coil3.ImageLoader | ||||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
| import com.acitelight.aether.model.Comic | import com.acitelight.aether.model.Comic | ||||||
| import com.acitelight.aether.model.ComicResponse | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.ApiClient.createOkHttp |  | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import dagger.hilt.android.lifecycle.HiltViewModel | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
| import dagger.hilt.android.qualifiers.ApplicationContext | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import kotlinx.coroutines.flow.MutableStateFlow |  | ||||||
| import kotlinx.coroutines.flow.StateFlow |  | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
|  |  | ||||||
| @HiltViewModel | @HiltViewModel | ||||||
| class ComicScreenViewModel @Inject constructor( | class ComicScreenViewModel @Inject constructor( | ||||||
|     @ApplicationContext private val context: Context, |     @ApplicationContext private val context: Context, | ||||||
|     val mediaManager: MediaManager |     val mediaManager: MediaManager, | ||||||
|  |     val apiClient: ApiClient | ||||||
| ) : ViewModel() { | ) : ViewModel() { | ||||||
|  |  | ||||||
|     var imageLoader: ImageLoader? = null; |     var imageLoader: ImageLoader? = null; | ||||||
|  |  | ||||||
|  |     val searchFilter = mutableStateOf("") | ||||||
|     val comics = mutableStateListOf<Comic>() |     val comics = mutableStateListOf<Comic>() | ||||||
|     val excluded = mutableStateListOf<String>() |     val excluded = mutableStateListOf<String>() | ||||||
|     val included = mutableStateListOf<String>() |     val included = mutableStateListOf<String>() | ||||||
| @@ -54,7 +52,7 @@ class ComicScreenViewModel @Inject constructor( | |||||||
|     init { |     init { | ||||||
|         imageLoader =  ImageLoader.Builder(context) |         imageLoader =  ImageLoader.Builder(context) | ||||||
|             .components { |             .components { | ||||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|             } |             } | ||||||
|             .build() |             .build() | ||||||
|  |  | ||||||
| @@ -63,7 +61,7 @@ class ComicScreenViewModel @Inject constructor( | |||||||
|             val m = mediaManager.queryComicInfoBulk(l) |             val m = mediaManager.queryComicInfoBulk(l) | ||||||
|  |  | ||||||
|             if(m != null) { |             if(m != null) { | ||||||
|                 comics.addAll(m.sortedWith(compareBy(naturalOrder()) { it.comic.comic_name })) |                 comics.addAll(m.sortedBy { it.id.toInt() }.reversed()) | ||||||
|                 tags.addAll(m.flatMap { it.comic.tags }.groupingBy { it }.eachCount() |                 tags.addAll(m.flatMap { it.comic.tags }.groupingBy { it }.eachCount() | ||||||
|                     .entries.sortedByDescending { it.value } |                     .entries.sortedByDescending { it.value } | ||||||
|                     .map { it.key }) |                     .map { it.key }) | ||||||
|   | |||||||
| @@ -5,8 +5,9 @@ import androidx.lifecycle.ViewModel | |||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import coil3.ImageLoader | import coil3.ImageLoader | ||||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
| import com.acitelight.aether.service.ApiClient.createOkHttp | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.RecentManager | import com.acitelight.aether.service.RecentManager | ||||||
|  | import com.acitelight.aether.service.VideoLibrary | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
| import dagger.hilt.android.lifecycle.HiltViewModel | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
| @@ -16,7 +17,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext | |||||||
| @HiltViewModel | @HiltViewModel | ||||||
| class HomeScreenViewModel @Inject constructor( | class HomeScreenViewModel @Inject constructor( | ||||||
|     val recentManager: RecentManager, |     val recentManager: RecentManager, | ||||||
|     @ApplicationContext val context: Context |     @ApplicationContext val context: Context, | ||||||
|  |     val videoLibrary: VideoLibrary, | ||||||
|  |     val apiClient: ApiClient | ||||||
| ) : ViewModel() | ) : ViewModel() | ||||||
| { | { | ||||||
|     var imageLoader: ImageLoader? = null |     var imageLoader: ImageLoader? = null | ||||||
| @@ -24,7 +27,7 @@ class HomeScreenViewModel @Inject constructor( | |||||||
|     init{ |     init{ | ||||||
|         imageLoader =  ImageLoader.Builder(context) |         imageLoader =  ImageLoader.Builder(context) | ||||||
|             .components { |             .components { | ||||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|             } |             } | ||||||
|             .build() |             .build() | ||||||
|         viewModelScope.launch { |         viewModelScope.launch { | ||||||
|   | |||||||
| @@ -0,0 +1,14 @@ | |||||||
|  | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
|  | import androidx.lifecycle.ViewModel | ||||||
|  | import com.acitelight.aether.service.ApiClient | ||||||
|  | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @HiltViewModel | ||||||
|  | class LiveScreenViewModel @Inject constructor( | ||||||
|  |     val apiClient: ApiClient | ||||||
|  | ) : ViewModel(){ | ||||||
|  |  | ||||||
|  | } | ||||||
| @@ -1,46 +1,42 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
| import android.app.Application |  | ||||||
| import android.content.Context | import android.content.Context | ||||||
| import android.widget.Toast | import android.widget.Toast | ||||||
| import androidx.compose.runtime.mutableStateOf | import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.compose.ui.platform.LocalContext |  | ||||||
| import androidx.core.net.toUri | import androidx.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.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import com.acitelight.aether.AetherApp | import com.acitelight.aether.AetherApp | ||||||
| import com.acitelight.aether.Global | import com.acitelight.aether.Global | ||||||
| import com.acitelight.aether.dataStore |  | ||||||
| import com.acitelight.aether.model.Video |  | ||||||
| import com.acitelight.aether.service.ApiClient | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.AuthManager | import com.acitelight.aether.service.AuthManager | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import kotlinx.coroutines.flow.Flow | import com.acitelight.aether.service.SettingsDataStoreManager | ||||||
| import kotlinx.coroutines.flow.MutableStateFlow |  | ||||||
| import 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.lifecycle.HiltViewModel | ||||||
| import dagger.hilt.android.qualifiers.ApplicationContext | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import kotlinx.coroutines.Dispatchers | import kotlinx.coroutines.Dispatchers | ||||||
|  | import kotlinx.coroutines.flow.first | ||||||
|  | import kotlinx.coroutines.launch | ||||||
| import kotlinx.coroutines.withContext | import kotlinx.coroutines.withContext | ||||||
|  | import java.net.DatagramPacket | ||||||
|  | import java.net.DatagramSocket | ||||||
|  | import java.net.InetAddress | ||||||
|  | import javax.inject.Inject | ||||||
|  |  | ||||||
| @HiltViewModel | @HiltViewModel | ||||||
| class MeScreenViewModel @Inject constructor( | class MeScreenViewModel @Inject constructor( | ||||||
|     private val settingsDataStoreManager: SettingsDataStoreManager, |     private val settingsDataStoreManager: SettingsDataStoreManager, | ||||||
|     @ApplicationContext private val context: Context, |     @ApplicationContext private val context: Context, | ||||||
|     val mediaManager: MediaManager |     val mediaManager: MediaManager, | ||||||
|  |     private val apiClient: ApiClient, | ||||||
|  |     private val authManager: AuthManager | ||||||
| ) : ViewModel() { | ) : ViewModel() { | ||||||
|  |  | ||||||
|     val username = mutableStateOf(""); |     val username = mutableStateOf("") | ||||||
|     val privateKey = mutableStateOf("") |     val privateKey = mutableStateOf("") | ||||||
|     val url = mutableStateOf(""); |     val url = mutableStateOf("") | ||||||
|     val cert = mutableStateOf("") |     val cert = mutableStateOf("") | ||||||
|  |     val pak = mutableStateOf("") | ||||||
|  |  | ||||||
|     val uss = settingsDataStoreManager.useSelfSignedFlow |     val uss = settingsDataStoreManager.useSelfSignedFlow | ||||||
|  |  | ||||||
| @@ -54,10 +50,9 @@ class MeScreenViewModel @Inject constructor( | |||||||
|             if(username.value=="" || privateKey.value=="" || url.value=="") return@launch |             if(username.value=="" || privateKey.value=="" || url.value=="") return@launch | ||||||
|  |  | ||||||
|             try{ |             try{ | ||||||
|                 val usedUrl = ApiClient.apply(context, url.value, if(uss.first()) cert.value else "") |                 apiClient.apply(context, url.value, if(uss.first()) cert.value else "") | ||||||
|  |  | ||||||
|                 if (mediaManager.token == "null") |                 authManager.fetchToken( | ||||||
|                     mediaManager.token = AuthManager.fetchToken( |  | ||||||
|                     username.value, |                     username.value, | ||||||
|                     settingsDataStoreManager.privateKeyFlow.first() |                     settingsDataStoreManager.privateKeyFlow.first() | ||||||
|                 )!! |                 )!! | ||||||
| @@ -65,7 +60,7 @@ class MeScreenViewModel @Inject constructor( | |||||||
|                 Global.loggedIn = true |                 Global.loggedIn = true | ||||||
|                 withContext(Dispatchers.IO) |                 withContext(Dispatchers.IO) | ||||||
|                 { |                 { | ||||||
|                     (context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096) |                     (context as AetherApp).abyssService?.proxy?.config(apiClient.getBase().toUri().host!!, 4096) | ||||||
|                     context.abyssService?.downloader?.init() |                     context.abyssService?.downloader?.init() | ||||||
|                 } |                 } | ||||||
|             }catch(e: Exception) |             }catch(e: Exception) | ||||||
| @@ -100,10 +95,10 @@ class MeScreenViewModel @Inject constructor( | |||||||
|             if (u == "" || p == "" || us == "") return@launch |             if (u == "" || p == "" || us == "") return@launch | ||||||
|  |  | ||||||
|             try { |             try { | ||||||
|                 val usedUrl = ApiClient.apply(context, u, if(uss.first()) c else "") |                 val usedUrl = apiClient.apply(context, u, if(uss.first()) c else "") | ||||||
|                 (context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096) |                 (context as AetherApp).abyssService?.proxy?.config(apiClient.getBase().toUri().host!!, 4096) | ||||||
|                 context.abyssService?.downloader?.init() |                 context.abyssService?.downloader?.init() | ||||||
|                 mediaManager.token = AuthManager.fetchToken( |                 authManager.fetchToken( | ||||||
|                     us, |                     us, | ||||||
|                     p |                     p | ||||||
|                 )!! |                 )!! | ||||||
| @@ -117,7 +112,8 @@ class MeScreenViewModel @Inject constructor( | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     fun updateAccount(u: String, p: String) { |     fun updateAccount(u: String, p: String) | ||||||
|  |     { | ||||||
|         viewModelScope.launch { |         viewModelScope.launch { | ||||||
|             settingsDataStoreManager.saveUserName(u) |             settingsDataStoreManager.saveUserName(u) | ||||||
|             settingsDataStoreManager.savePrivateKey(p) |             settingsDataStoreManager.savePrivateKey(p) | ||||||
| @@ -133,7 +129,7 @@ class MeScreenViewModel @Inject constructor( | |||||||
|             if (u == "" || p == "" || ur == "") return@launch |             if (u == "" || p == "" || ur == "") return@launch | ||||||
|  |  | ||||||
|             try { |             try { | ||||||
|                 mediaManager.token = AuthManager.fetchToken( |                 authManager.fetchToken( | ||||||
|                     u, |                     u, | ||||||
|                     p |                     p | ||||||
|                 )!! |                 )!! | ||||||
| @@ -141,7 +137,7 @@ class MeScreenViewModel @Inject constructor( | |||||||
|                 Global.loggedIn = true |                 Global.loggedIn = true | ||||||
|                 withContext(Dispatchers.IO) |                 withContext(Dispatchers.IO) | ||||||
|                 { |                 { | ||||||
|                     (context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096) |                     (context as AetherApp).abyssService?.proxy?.config(apiClient.getBase().toUri().host!!, 4096) | ||||||
|                     context.abyssService?.downloader?.init() |                     context.abyssService?.downloader?.init() | ||||||
|                 } |                 } | ||||||
|                 Toast.makeText(context, "Account Updated", Toast.LENGTH_SHORT).show() |                 Toast.makeText(context, "Account Updated", Toast.LENGTH_SHORT).show() | ||||||
| @@ -151,4 +147,22 @@ class MeScreenViewModel @Inject constructor( | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     fun sendPacket(p: String) | ||||||
|  |     { | ||||||
|  |         val b = (p + "\r\n").toByteArray(Charsets.UTF_8) | ||||||
|  |  | ||||||
|  |         viewModelScope.launch { | ||||||
|  |             withContext(Dispatchers.IO) { | ||||||
|  |                 val addr = InetAddress.getByName(apiClient.getDomain()) | ||||||
|  |  | ||||||
|  |                 val socket = DatagramSocket() | ||||||
|  |                 val packet = DatagramPacket( | ||||||
|  |                     b, b.size, addr, 4096 | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |                 socket.send(packet) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
| @@ -5,68 +5,150 @@ import androidx.compose.runtime.mutableStateListOf | |||||||
| import androidx.compose.runtime.snapshots.SnapshotStateList | import androidx.compose.runtime.snapshots.SnapshotStateList | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import com.acitelight.aether.model.DownloadItemState | import androidx.navigation.NavHostController | ||||||
|  | import coil3.ImageLoader | ||||||
|  | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
|  | import com.acitelight.aether.Global.updateRelate | ||||||
|  | import com.acitelight.aether.model.Video | ||||||
|  | import com.acitelight.aether.model.VideoDownloadItemState | ||||||
|  | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.FetchManager | import com.acitelight.aether.service.FetchManager | ||||||
|  | import com.acitelight.aether.service.MediaManager | ||||||
| import com.acitelight.aether.service.VideoLibrary | import com.acitelight.aether.service.VideoLibrary | ||||||
|  | import com.acitelight.aether.view.pages.toHex | ||||||
| import com.tonyodev.fetch2.Download | import com.tonyodev.fetch2.Download | ||||||
| import com.tonyodev.fetch2.FetchListener | import com.tonyodev.fetch2.FetchListener | ||||||
|  | import com.tonyodev.fetch2.Status | ||||||
| import com.tonyodev.fetch2core.DownloadBlock | import com.tonyodev.fetch2core.DownloadBlock | ||||||
| import dagger.hilt.android.lifecycle.HiltViewModel | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
| import dagger.hilt.android.qualifiers.ApplicationContext | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import kotlinx.coroutines.Dispatchers | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
| import kotlinx.coroutines.withContext | import kotlinx.coroutines.withContext | ||||||
|  | import kotlinx.serialization.json.Json | ||||||
|  | import java.io.File | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
|  |  | ||||||
| @HiltViewModel | @HiltViewModel | ||||||
| class TransmissionScreenViewModel @Inject constructor( | class TransmissionScreenViewModel @Inject constructor( | ||||||
|     val fetchManager: FetchManager, |     val fetchManager: FetchManager, | ||||||
|     @ApplicationContext val context: Context, |     @ApplicationContext val context: Context, | ||||||
|     private val videoLibrary: VideoLibrary |     val videoLibrary: VideoLibrary, | ||||||
|  |     val mediaManager: MediaManager, | ||||||
|  |     val apiClient: ApiClient | ||||||
| ) : ViewModel() { | ) : ViewModel() { | ||||||
|     private val _downloads: SnapshotStateList<DownloadItemState> = mutableStateListOf() |     var imageLoader: ImageLoader? = null | ||||||
|     val downloads: SnapshotStateList<DownloadItemState> = _downloads |     val downloads: SnapshotStateList<VideoDownloadItemState> = mutableStateListOf() | ||||||
|  |  | ||||||
|     // map id -> state object reference (no index bookkeeping) |     // map id -> state object reference (no index bookkeeping) | ||||||
|     private val idToState: MutableMap<Int, DownloadItemState> = mutableMapOf() |     private val idToState: MutableMap<Int, VideoDownloadItemState> = mutableMapOf() | ||||||
|  |  | ||||||
|  |     fun modelToVideo(model: VideoDownloadItemState): Video? { | ||||||
|  |         val fv = videoLibrary.classesMap.map { it.value }.flatten() | ||||||
|  |         return fv.firstOrNull { it.klass == model.klass && it.id == model.vid } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     private val fetchListener = object : FetchListener { |     private val fetchListener = object : FetchListener { | ||||||
|         override fun onAdded(download: Download) { handleUpsert(download) } |         override fun onAdded(download: Download) { | ||||||
|         override fun onQueued(download: Download, waitingOnNetwork: Boolean) { handleUpsert(download) } |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onQueued(download: Download, waitingOnNetwork: Boolean) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|         override fun onWaitingNetwork(download: Download) { |         override fun onWaitingNetwork(download: Download) { | ||||||
|  |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         override fun onProgress(download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long) { handleUpsert(download) } |         override fun onProgress( | ||||||
|         override fun onPaused(download: Download) { handleUpsert(download) } |             download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long | ||||||
|         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) |             handleUpsert(download) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         override fun onError(download: Download, error: com.tonyodev.fetch2.Error, throwable: Throwable?) { handleUpsert(download) } |         override fun onPaused(download: Download) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onResumed(download: Download) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onCompleted(download: Download) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |  | ||||||
|  |             if (download.extras.getString("type", "") == "main") { | ||||||
|  |                 val ii = videoLibrary.classesMap[download.extras.getString( | ||||||
|  |                     "class", | ||||||
|  |                     "" | ||||||
|  |                 )]?.indexOfFirst { it.id == download.extras.getString("id", "") } | ||||||
|  |  | ||||||
|  |                 if (ii != null) { | ||||||
|  |                     val newi = | ||||||
|  |                         videoLibrary.classesMap[download.extras.getString("class", "")]?.get(ii) | ||||||
|  |                     if (newi != null) videoLibrary.classesMap[download.extras.getString( | ||||||
|  |                         "class", | ||||||
|  |                         "" | ||||||
|  |                     )]?.set( | ||||||
|  |                         ii, newi.toLocal(context.getExternalFilesDir(null)!!.path) | ||||||
|  |                     ) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onCancelled(download: Download) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onRemoved(download: Download) { | ||||||
|  |             handleRemove(download.id) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onDeleted(download: Download) { | ||||||
|  |             handleRemove(download.id) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onDownloadBlockUpdated( | ||||||
|  |             download: Download, downloadBlock: DownloadBlock, totalBlocks: Int | ||||||
|  |         ) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onStarted( | ||||||
|  |             download: Download, downloadBlocks: List<DownloadBlock>, totalBlocks: Int | ||||||
|  |         ) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         override fun onError( | ||||||
|  |             download: Download, error: com.tonyodev.fetch2.Error, throwable: Throwable? | ||||||
|  |         ) { | ||||||
|  |             handleUpsert(download) | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun handleUpsert(download: Download) { |     private fun handleUpsert(download: Download) { | ||||||
|         viewModelScope.launch(Dispatchers.Main) { |         viewModelScope.launch(Dispatchers.Main) { | ||||||
|             upsertOnMain(download) |             upsertOnMain(download) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         val state = downloadToState(download) | ||||||
|  |  | ||||||
|  |         if (!videoLibrary.classes.contains(state.klass)) videoLibrary.classes.add(state.klass) | ||||||
|  |  | ||||||
|  |         if (!videoLibrary.classesMap.containsKey(state.klass)) videoLibrary.classesMap[state.klass] = | ||||||
|  |             mutableStateListOf() | ||||||
|  |  | ||||||
|  |         if (videoLibrary.classesMap[state.klass]?.any { it.id == state.vid } != true) { | ||||||
|  |             viewModelScope.launch(Dispatchers.IO) { | ||||||
|  |                 val v = mediaManager.queryVideo(state.klass, state.vid, state) | ||||||
|  |                 if (v != null) { | ||||||
|  |                     videoLibrary.classesMap[state.klass]?.add(v) | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     private fun handleRemove(id: Int) { |     private fun handleRemove(id: Int) { | ||||||
| @@ -89,7 +171,7 @@ class TransmissionScreenViewModel @Inject constructor( | |||||||
|         } else { |         } else { | ||||||
|             // new item: add to head (or tail depending on preference) |             // new item: add to head (or tail depending on preference) | ||||||
|             val newState = downloadToState(download) |             val newState = downloadToState(download) | ||||||
|             _downloads.add(0, newState) |             downloads.add(0, newState) | ||||||
|             idToState[newState.id] = newState |             idToState[newState.id] = newState | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -97,19 +179,20 @@ class TransmissionScreenViewModel @Inject constructor( | |||||||
|     private fun removeOnMain(id: Int) { |     private fun removeOnMain(id: Int) { | ||||||
|         val state = idToState.remove(id) |         val state = idToState.remove(id) | ||||||
|         if (state != null) { |         if (state != null) { | ||||||
|             _downloads.remove(state) |             downloads.remove(state) | ||||||
|         } else { |         } else { | ||||||
|             val idx = _downloads.indexOfFirst { it.id == id } |             val idx = downloads.indexOfFirst { it.id == id } | ||||||
|             if (idx >= 0) { |             if (idx >= 0) { | ||||||
|                 val removed = _downloads.removeAt(idx) |                 val removed = downloads.removeAt(idx) | ||||||
|                 idToState.remove(removed.id) |                 idToState.remove(removed.id) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     private fun downloadToState(download: Download): DownloadItemState { |  | ||||||
|  |     private fun downloadToState(download: Download): VideoDownloadItemState { | ||||||
|         val filePath = download.file |         val filePath = download.file | ||||||
|  |  | ||||||
|         return DownloadItemState( |         return VideoDownloadItemState( | ||||||
|             id = download.id, |             id = download.id, | ||||||
|             fileName = download.request.extras.getString("name", ""), |             fileName = download.request.extras.getString("name", ""), | ||||||
|             filePath = filePath, |             filePath = filePath, | ||||||
| @@ -119,7 +202,8 @@ class TransmissionScreenViewModel @Inject constructor( | |||||||
|             downloadedBytes = download.downloaded, |             downloadedBytes = download.downloaded, | ||||||
|             totalBytes = download.total, |             totalBytes = download.total, | ||||||
|             klass = download.extras.getString("class", ""), |             klass = download.extras.getString("class", ""), | ||||||
|             vid = download.extras.getString("id", "") |             vid = download.extras.getString("id", ""), | ||||||
|  |             type = download.extras.getString("type", "") | ||||||
|         ) |         ) | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -127,8 +211,8 @@ class TransmissionScreenViewModel @Inject constructor( | |||||||
|     // UI actions delegated to FetchManager |     // UI actions delegated to FetchManager | ||||||
|     fun pause(id: Int) = fetchManager.pause(id) |     fun pause(id: Int) = fetchManager.pause(id) | ||||||
|     fun resume(id: Int) = fetchManager.resume(id) |     fun resume(id: Int) = fetchManager.resume(id) | ||||||
|     fun cancel(id: Int) = fetchManager.cancel(id) |     fun retry(id: Int) = fetchManager.retry(id) | ||||||
|     fun delete(id: Int, deleteFile: Boolean = true) { |     fun delete(id: Int) { | ||||||
|         fetchManager.delete(id) { |         fetchManager.delete(id) { | ||||||
|             viewModelScope.launch(Dispatchers.Main) { removeOnMain(id) } |             viewModelScope.launch(Dispatchers.Main) { removeOnMain(id) } | ||||||
|         } |         } | ||||||
| @@ -139,17 +223,84 @@ class TransmissionScreenViewModel @Inject constructor( | |||||||
|         fetchManager.removeListener() |         fetchManager.removeListener() | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     suspend fun playStart(model: VideoDownloadItemState, navigator: NavHostController) | ||||||
|  |     { | ||||||
|  |         val downloaded = fetchManager.getAllDownloadsAsync().filter { | ||||||
|  |             it.status == Status.COMPLETED && it.extras.getString( | ||||||
|  |                 "class", | ||||||
|  |                 "" | ||||||
|  |             ) != "comic" && it.extras.getString( | ||||||
|  |                 "type", | ||||||
|  |                 "" | ||||||
|  |             ) == "main" | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val jsonQuery = downloaded.map { | ||||||
|  |             File( | ||||||
|  |                 context.getExternalFilesDir(null), | ||||||
|  |                 "videos/${ | ||||||
|  |                     it.extras.getString( | ||||||
|  |                         "class", | ||||||
|  |                         "" | ||||||
|  |                     ) | ||||||
|  |                 }/${it.extras.getString("id", "")}/summary.json" | ||||||
|  |             ).readText() | ||||||
|  |         } | ||||||
|  |             .map { | ||||||
|  |                 Json.decodeFromString<Video>(it) | ||||||
|  |                     .toLocal(context.getExternalFilesDir(null)!!.path) | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |         updateRelate( | ||||||
|  |             jsonQuery, | ||||||
|  |             jsonQuery.first { it.id == model.vid && it.klass == model.klass } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         val playList = mutableListOf<String>() | ||||||
|  |         val fv = videoLibrary.classesMap.map { it.value }.flatten() | ||||||
|  |         val video = fv.firstOrNull { it.klass == model.klass && it.id == model.vid } | ||||||
|  |  | ||||||
|  |         if (video != null) { | ||||||
|  |             val group = fv.filter { it.klass == video.klass && it.video.group == video.video.group && it.video.group != "null" } | ||||||
|  |             for (i in group.sortedWith(compareBy(naturalOrder()) { it.video.name })) { | ||||||
|  |                 playList.add("${i.klass}/${i.id}") | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val route = "video_player_route/${(playList.joinToString(",") + "|${model.vid}").toHex()}" | ||||||
|  |         withContext(Dispatchers.Main) { | ||||||
|  |             navigator.navigate(route) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|     init { |     init { | ||||||
|  |         imageLoader = ImageLoader.Builder(context).components { | ||||||
|  |             add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|  |         }.build() | ||||||
|  |  | ||||||
|         viewModelScope.launch { |         viewModelScope.launch { | ||||||
|             fetchManager.setListener(fetchListener) |             fetchManager.setListener(fetchListener) | ||||||
|             withContext(Dispatchers.Main) { |             val downloaded = fetchManager.getAllDownloadsAsync() | ||||||
|                 fetchManager.getAllDownloads { list -> |  | ||||||
|                     _downloads.clear() |             downloads.clear() | ||||||
|             idToState.clear() |             idToState.clear() | ||||||
|                     list.sortedBy { it.extras.getString("name", "") }.forEach { d -> |             downloaded.forEach { d -> | ||||||
|                 val s = downloadToState(d) |                 val s = downloadToState(d) | ||||||
|                         _downloads.add(s) |                 downloads.add(s) | ||||||
|                 idToState[s.id] = s |                 idToState[s.id] = s | ||||||
|  |  | ||||||
|  |                 if (d.extras.getString("type", "") == "main") { | ||||||
|  |                     if (!videoLibrary.classes.contains(s.klass)) | ||||||
|  |                         videoLibrary.classes.add(s.klass) | ||||||
|  |  | ||||||
|  |                     if (!videoLibrary.classesMap.containsKey(s.klass)) videoLibrary.classesMap[s.klass] = | ||||||
|  |                         mutableStateListOf() | ||||||
|  |  | ||||||
|  |                     if (videoLibrary.classesMap[s.klass]?.any { it.id == s.vid } != true) { | ||||||
|  |                         val v = mediaManager.queryVideo(s.klass, s.vid, s) | ||||||
|  |                         if (v != null) { | ||||||
|  |                             videoLibrary.classesMap[s.klass]?.add(v) | ||||||
|  |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -4,58 +4,63 @@ import android.content.Context | |||||||
| import android.net.Uri | import android.net.Uri | ||||||
| import android.widget.Toast | import android.widget.Toast | ||||||
| import androidx.annotation.OptIn | import androidx.annotation.OptIn | ||||||
| import androidx.compose.runtime.Composable |  | ||||||
| import androidx.compose.runtime.getValue | import androidx.compose.runtime.getValue | ||||||
| import androidx.compose.runtime.mutableFloatStateOf | import androidx.compose.runtime.mutableFloatStateOf | ||||||
| import androidx.compose.runtime.mutableIntStateOf | import androidx.compose.runtime.mutableIntStateOf | ||||||
|  | import androidx.compose.runtime.mutableLongStateOf | ||||||
| import androidx.compose.runtime.mutableStateOf | import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.compose.runtime.remember |  | ||||||
| import androidx.compose.runtime.setValue | import androidx.compose.runtime.setValue | ||||||
| import androidx.compose.ui.platform.LocalContext | import androidx.core.net.toUri | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import androidx.media3.common.MediaItem | import androidx.media3.common.MediaItem | ||||||
| import androidx.media3.common.PlaybackException | import androidx.media3.common.PlaybackException | ||||||
| import androidx.media3.common.Player | import androidx.media3.common.Player | ||||||
|  | import androidx.media3.common.Player.STATE_ENDED | ||||||
| import androidx.media3.common.Player.STATE_READY | import androidx.media3.common.Player.STATE_READY | ||||||
|  | import androidx.media3.common.Tracks | ||||||
| import androidx.media3.common.text.Cue | import androidx.media3.common.text.Cue | ||||||
| import androidx.media3.common.util.Log |  | ||||||
| import androidx.media3.common.util.UnstableApi | import androidx.media3.common.util.UnstableApi | ||||||
|  | import androidx.media3.datasource.DefaultDataSource | ||||||
| import androidx.media3.datasource.okhttp.OkHttpDataSource | import androidx.media3.datasource.okhttp.OkHttpDataSource | ||||||
| import androidx.media3.exoplayer.ExoPlayer | import androidx.media3.exoplayer.ExoPlayer | ||||||
| import androidx.media3.exoplayer.source.DefaultMediaSourceFactory | import androidx.media3.exoplayer.source.DefaultMediaSourceFactory | ||||||
|  | import androidx.media3.exoplayer.trackselection.DefaultTrackSelector | ||||||
| import coil3.ImageLoader | import coil3.ImageLoader | ||||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
|  | import com.acitelight.aether.model.KeyImage | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import com.acitelight.aether.model.VideoQueryIndex | import com.acitelight.aether.model.VideoQueryIndex | ||||||
| import com.acitelight.aether.model.VideoRecord | import com.acitelight.aether.model.VideoRecord | ||||||
| import com.acitelight.aether.model.VideoRecordDatabase | import com.acitelight.aether.model.VideoRecordDatabase | ||||||
| import com.acitelight.aether.service.ApiClient.createOkHttp | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import com.acitelight.aether.service.RecentManager | import com.acitelight.aether.service.RecentManager | ||||||
| import com.acitelight.aether.view.formatTime | import com.acitelight.aether.service.VideoLibrary | ||||||
| import com.acitelight.aether.view.hexToString | import com.acitelight.aether.view.pages.formatTime | ||||||
|  | import com.acitelight.aether.view.pages.hexToString | ||||||
| import dagger.hilt.android.lifecycle.HiltViewModel | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
| import dagger.hilt.android.qualifiers.ApplicationContext | import dagger.hilt.android.qualifiers.ApplicationContext | ||||||
| import kotlinx.coroutines.CoroutineScope | import kotlinx.coroutines.CoroutineScope | ||||||
| import kotlinx.coroutines.Dispatchers | import kotlinx.coroutines.Dispatchers | ||||||
| import kotlinx.coroutines.delay | import kotlinx.coroutines.delay | ||||||
|  | import kotlinx.coroutines.flow.first | ||||||
| import kotlinx.coroutines.launch | import kotlinx.coroutines.launch | ||||||
| import kotlinx.coroutines.withContext | import kotlinx.coroutines.withContext | ||||||
| import okhttp3.Request | import okhttp3.Request | ||||||
| import java.io.File | import java.io.File | ||||||
| import javax.inject.Inject | 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 |  | ||||||
|  |  | ||||||
| @HiltViewModel | @HiltViewModel | ||||||
| class VideoPlayerViewModel @Inject constructor( | class VideoPlayerViewModel @Inject constructor( | ||||||
|     @ApplicationContext private val context: Context, |     @ApplicationContext private val context: Context, | ||||||
|     val mediaManager: MediaManager, |     val mediaManager: MediaManager, | ||||||
|     val recentManager: RecentManager |     val recentManager: RecentManager, | ||||||
|  |     val videoLibrary: VideoLibrary, | ||||||
|  |     val apiClient: ApiClient | ||||||
| ) : ViewModel() { | ) : ViewModel() { | ||||||
|  |     var showPlaylist by mutableStateOf(false) | ||||||
|  |     var isLandscape by mutableStateOf(false) | ||||||
|     var tabIndex by mutableIntStateOf(0) |     var tabIndex by mutableIntStateOf(0) | ||||||
|     var isPlaying by mutableStateOf(true) |     var isPlaying by mutableStateOf(true) | ||||||
|     var playProcess by mutableFloatStateOf(0.0f) |     var playProcess by mutableFloatStateOf(0.0f) | ||||||
| @@ -67,121 +72,69 @@ class VideoPlayerViewModel @Inject constructor( | |||||||
|     // 1  : Volume |     // 1  : Volume | ||||||
|     // 2  : Brightness |     // 2  : Brightness | ||||||
|     var draggingPurpose by mutableIntStateOf(-1) |     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) |     var locked by mutableStateOf(false) | ||||||
|     private var _init: Boolean = false; |     private var _init: Boolean = false | ||||||
|     var startPlaying by mutableStateOf(false) |     var startPlaying by mutableStateOf(false) | ||||||
|     var renderedFirst = false |     var renderedFirst = false | ||||||
|     var video: Video? = null |     var videos: List<Video> = listOf() | ||||||
|  |  | ||||||
|     val dataSourceFactory = OkHttpDataSource.Factory(createOkHttp()) |     private val httpDataSourceFactory = OkHttpDataSource.Factory(apiClient.getClient()) | ||||||
|     var imageLoader: ImageLoader? = null; |     private val defaultDataSourceFactory by lazy { | ||||||
|     var brit by mutableFloatStateOf(0.5f) |         DefaultDataSource.Factory( | ||||||
|  |             context, | ||||||
|  |             httpDataSourceFactory | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var imageLoader: ImageLoader? = null | ||||||
|  |     var brit by mutableFloatStateOf(0.0f) | ||||||
|     val database: VideoRecordDatabase = VideoRecordDatabase.getDatabase(context) |     val database: VideoRecordDatabase = VideoRecordDatabase.getDatabase(context) | ||||||
|     var cues by mutableStateOf(listOf<Cue>()) |     var cues by mutableStateOf(listOf<Cue>()) | ||||||
|  |     var currentKlass = mutableStateOf("") | ||||||
|  |     var currentId = mutableStateOf("") | ||||||
|  |     var currentName = mutableStateOf("") | ||||||
|  |     var currentDuration = mutableLongStateOf(0) | ||||||
|  |     var currentGallery = mutableStateOf(listOf<KeyImage>()) | ||||||
|  |  | ||||||
|     @OptIn(UnstableApi::class) |     @OptIn(UnstableApi::class) | ||||||
|     fun init(videoId: String) { |     fun init(videoId: String) { | ||||||
|         if (_init) return; |         if (_init) | ||||||
|         val v = videoId.hexToString() |             return | ||||||
|  |         _init = true | ||||||
|  |  | ||||||
|  |         val oId = videoId.hexToString() | ||||||
|  |         var spec = "-1" | ||||||
|  |         var vs: MutableList<List<String>> | ||||||
|  |  | ||||||
|  |         if(oId.contains("|")) | ||||||
|  |         { | ||||||
|  |             vs = oId.split("|")[0].split(",").map { it.split("/") }.toMutableList() | ||||||
|  |             spec = oId.split("|")[1] | ||||||
|  |         }else{ | ||||||
|  |             vs = oId.split(",").map { it.split("/") }.toMutableList() | ||||||
|  |         } | ||||||
|  |  | ||||||
|         imageLoader = ImageLoader.Builder(context) |         imageLoader = ImageLoader.Builder(context) | ||||||
|             .components { |             .components { | ||||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|             } |             } | ||||||
|             .build() |             .build() | ||||||
|  |  | ||||||
|         viewModelScope.launch { |         viewModelScope.launch { | ||||||
|             video = mediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!! |             videos = mediaManager.queryVideoBulk(vs.first()[0], vs.map { it[1] })!! | ||||||
|             recentManager.pushVideo(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1])) |  | ||||||
|  |  | ||||||
|             val subtitleCandidate = video?.getSubtitle()?.trim() |             val ii = database.userDao().getAll().first() | ||||||
|             val subtitleUri = tryResolveSubtitleUri(subtitleCandidate) |             val ix = ii.filter { it.id in videos.map{ m -> m.id } }.maxByOrNull { it.time } | ||||||
|  |  | ||||||
|             // decide whether we need network-capable media source factory: |             startPlay( | ||||||
|             val subtitleIsRemote = subtitleUri?.scheme?.startsWith("http", ignoreCase = true) == true |                 if(spec != "-1") | ||||||
|             val videoIsRemote = !video!!.isLocal |                     videos.first { it.id == spec} | ||||||
|             val needNetworkFactory = videoIsRemote || subtitleIsRemote |                 else if (ix != null) | ||||||
|             val trackSelector = DefaultTrackSelector(context) |                     videos.first { it.id == ix.id } | ||||||
|  |                 else videos.first() | ||||||
|             // 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 onPlayerError(error: PlaybackException) |  | ||||||
|                     { |  | ||||||
|                         print(error.message) |  | ||||||
|                     } |  | ||||||
|  |  | ||||||
|                     override fun onCues(lcues: MutableList<Cue>) { |  | ||||||
|                         cues = lcues |  | ||||||
|                     } |  | ||||||
|                 }) |  | ||||||
|             } |  | ||||||
|             startListen() |             startListen() | ||||||
|         } |         } | ||||||
|         _init = true; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     /** | ||||||
| @@ -190,17 +143,26 @@ class VideoPlayerViewModel @Inject constructor( | |||||||
|      * - If it's a http(s) URL -> try HEAD; if HEAD unsupported, try GET with Range: bytes=0-1 |      * - 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 |      * - Return null when unreachable / 404 / not exist | ||||||
|      */ |      */ | ||||||
|     private suspend fun tryResolveSubtitleUri(pathOrUrl: String?): Uri? = withContext(Dispatchers.IO) { |     private suspend fun tryResolveSubtitleUri(pathOrUrl: String?): Uri? = | ||||||
|  |         withContext(Dispatchers.IO) { | ||||||
|             if (pathOrUrl.isNullOrBlank()) return@withContext null |             if (pathOrUrl.isNullOrBlank()) return@withContext null | ||||||
|             val trimmed = pathOrUrl.trim() |             val trimmed = pathOrUrl.trim() | ||||||
|  |  | ||||||
|             // Remote URL case (http/https) |             // Remote URL case (http/https) | ||||||
|         if (trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith("https://", ignoreCase = true)) { |             if (trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith( | ||||||
|  |                     "https://", | ||||||
|  |                     ignoreCase = true | ||||||
|  |                 ) | ||||||
|  |             ) { | ||||||
|                 try { |                 try { | ||||||
|                 val client = createOkHttp() |                     val client = apiClient.getClient() | ||||||
|  |  | ||||||
|                 var headReq = Request.Builder().url(trimmed).head().build() |                     val headReq = Request.Builder().url(trimmed).head().build() | ||||||
|                 var headResp = try { client.newCall(headReq).execute() } catch (e: Exception) { null } |                     val headResp = try { | ||||||
|  |                         client.newCall(headReq).execute() | ||||||
|  |                     } catch (_: Exception) { | ||||||
|  |                         null | ||||||
|  |                     } | ||||||
|  |  | ||||||
|                     headResp?.use { resp -> |                     headResp?.use { resp -> | ||||||
|                         val code = resp.code |                         val code = resp.code | ||||||
| @@ -217,7 +179,11 @@ class VideoPlayerViewModel @Inject constructor( | |||||||
|                         .get() |                         .get() | ||||||
|                         .build() |                         .build() | ||||||
|  |  | ||||||
|                 var rangeResp = try { client.newCall(rangeReq).execute() } catch (e: Exception) { null } |                     val rangeResp = try { | ||||||
|  |                         client.newCall(rangeReq).execute() | ||||||
|  |                     } catch (_: Exception) { | ||||||
|  |                         null | ||||||
|  |                     } | ||||||
|  |  | ||||||
|                     rangeResp?.use { resp -> |                     rangeResp?.use { resp -> | ||||||
|                         val code = resp.code |                         val code = resp.code | ||||||
| @@ -233,7 +199,7 @@ class VideoPlayerViewModel @Inject constructor( | |||||||
|                             return@withContext null |                             return@withContext null | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|             } catch (e: Exception) { |                 } catch (_: Exception) { | ||||||
|                     return@withContext null |                     return@withContext null | ||||||
|                 } |                 } | ||||||
|                 return@withContext null |                 return@withContext null | ||||||
| @@ -247,22 +213,148 @@ class VideoPlayerViewModel @Inject constructor( | |||||||
|     @OptIn(UnstableApi::class) |     @OptIn(UnstableApi::class) | ||||||
|     fun startListen() { |     fun startListen() { | ||||||
|         CoroutineScope(Dispatchers.Main).launch { |         CoroutineScope(Dispatchers.Main).launch { | ||||||
|             while (_player?.isReleased != true) { |             while (_init) { | ||||||
|                 val __player = _player!!; |                 player?.let { playProcess = it.currentPosition.toFloat() / it.duration.toFloat() } | ||||||
|                 playProcess = __player.currentPosition.toFloat() / __player.duration.toFloat() |  | ||||||
|                 delay(100) |                 delay(100) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     var _player: ExoPlayer? = null; |     @OptIn(UnstableApi::class) | ||||||
|  |     suspend fun startPlay(video: Video) { | ||||||
|  |         if (currentId.value.isNotEmpty() && currentKlass.value.isNotEmpty()) { | ||||||
|  |             val pos = player?.currentPosition ?: 0L | ||||||
|  |             database.userDao().insert( | ||||||
|  |                 VideoRecord( | ||||||
|  |                     currentId.value, | ||||||
|  |                     currentKlass.value, | ||||||
|  |                     pos, | ||||||
|  |                     System.currentTimeMillis(), | ||||||
|  |                     videos.joinToString(",") { it.id }) | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         renderedFirst = false | ||||||
|  |         currentId.value = video.id | ||||||
|  |         currentKlass.value = video.klass | ||||||
|  |         currentName.value = video.video.name | ||||||
|  |         currentDuration.longValue = video.video.duration | ||||||
|  |         currentGallery.value = video.getGallery(apiClient) | ||||||
|  |  | ||||||
|  |         player?.apply { | ||||||
|  |             stop() | ||||||
|  |             clearMediaItems() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         recentManager.pushVideo(context, VideoQueryIndex(video.klass, video.id)) | ||||||
|  |  | ||||||
|  |         val subtitleCandidate = video.getSubtitle(apiClient).trim() | ||||||
|  |         val subtitleUri = tryResolveSubtitleUri(subtitleCandidate) | ||||||
|  |  | ||||||
|  |         if (player == null) { | ||||||
|  |             val trackSelector = DefaultTrackSelector(context) | ||||||
|  |             val builder = ExoPlayer.Builder(context) | ||||||
|  |                 .setMediaSourceFactory(DefaultMediaSourceFactory(defaultDataSourceFactory)) | ||||||
|  |  | ||||||
|  |             player = builder.setTrackSelector(trackSelector).build().apply { | ||||||
|  |                 addListener(object : Player.Listener { | ||||||
|  |                     override fun onTracksChanged(tracks: Tracks) { | ||||||
|  |                         val trackSelector = player?.trackSelector | ||||||
|  |                         if (trackSelector is DefaultTrackSelector) { | ||||||
|  |                             val parameters = trackSelector.buildUponParameters() | ||||||
|  |                                 .setSelectUndeterminedTextLanguage(true) | ||||||
|  |                                 .build() | ||||||
|  |                             trackSelector.parameters = parameters | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     override fun onPlaybackStateChanged(playbackState: Int) { | ||||||
|  |                         when(playbackState) | ||||||
|  |                         { | ||||||
|  |                             STATE_READY -> { | ||||||
|  |                                 startPlaying = true | ||||||
|  |                             } | ||||||
|  |                             STATE_ENDED -> { | ||||||
|  |                                 player?.seekTo(0) | ||||||
|  |                                 player?.pause() | ||||||
|  |                             } | ||||||
|  |                             else -> { | ||||||
|  |  | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     override fun onRenderedFirstFrame() { | ||||||
|  |                         if (!renderedFirst) { | ||||||
|  |                             viewModelScope.launch { | ||||||
|  |                                 val ii = database.userDao().get(currentId.value, currentKlass.value) | ||||||
|  |                                 if (ii != null) { | ||||||
|  |                                     player?.seekTo(ii.position) | ||||||
|  |                                     Toast.makeText( | ||||||
|  |                                         context, | ||||||
|  |                                         "Recover from ${formatTime(ii.position)} ", | ||||||
|  |                                         Toast.LENGTH_SHORT | ||||||
|  |                                     ).show() | ||||||
|  |                                 } | ||||||
|  |                             } | ||||||
|  |                         } | ||||||
|  |                         renderedFirst = true | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     override fun onPlayerError(error: PlaybackException) { | ||||||
|  |                         print(error.message) | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     override fun onCues(lcues: MutableList<Cue>) { | ||||||
|  |                         cues = lcues | ||||||
|  |                     } | ||||||
|  |                 }) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         val url = video.getVideo(apiClient) | ||||||
|  |         val videoUri = if (video.isLocal) Uri.fromFile(File(url)) else url.toUri() | ||||||
|  |  | ||||||
|  |         val mediaItem: MediaItem = if (subtitleUri != null) { | ||||||
|  |             val subConfig = MediaItem.SubtitleConfiguration.Builder(subtitleUri) | ||||||
|  |                 .setMimeType("text/vtt") | ||||||
|  |                 .build() | ||||||
|  |  | ||||||
|  |             MediaItem.Builder() | ||||||
|  |                 .setUri(videoUri) | ||||||
|  |                 .setSubtitleConfigurations(listOf(subConfig)) | ||||||
|  |                 .build() | ||||||
|  |         } else { | ||||||
|  |             MediaItem.fromUri(videoUri) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         player?.apply { | ||||||
|  |             setMediaItem(mediaItem) | ||||||
|  |             prepare() | ||||||
|  |             playWhenReady = true | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var player: ExoPlayer? = null | ||||||
|  |  | ||||||
|     override fun onCleared() { |     override fun onCleared() { | ||||||
|         super.onCleared() |         super.onCleared() | ||||||
|         val p = _player!!.currentPosition |  | ||||||
|         _player?.release() |         _init = false | ||||||
|  |         val pos = player?.currentPosition ?: 0L | ||||||
|  |         player?.release() | ||||||
|  |         player = null | ||||||
|  |  | ||||||
|         CoroutineScope(Dispatchers.IO).launch { |         CoroutineScope(Dispatchers.IO).launch { | ||||||
|             database.userDao().insert(VideoRecord(video!!.id, video!!.klass, p)) |             if (currentId.value.isNotEmpty() && currentKlass.value.isNotEmpty()) | ||||||
|  |                 database.userDao().insert( | ||||||
|  |                     VideoRecord( | ||||||
|  |                         currentId.value, | ||||||
|  |                         currentKlass.value, | ||||||
|  |                         pos, | ||||||
|  |                         System.currentTimeMillis(), | ||||||
|  |                         videos.joinToString(",") { it.id }) | ||||||
|  |                 ) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,25 +1,19 @@ | |||||||
| package com.acitelight.aether.viewModel | package com.acitelight.aether.viewModel | ||||||
|  |  | ||||||
| import android.app.Application |  | ||||||
| import android.content.Context | import android.content.Context | ||||||
| import androidx.compose.runtime.Composable |  | ||||||
| import androidx.compose.runtime.State | import androidx.compose.runtime.State | ||||||
| import androidx.compose.runtime.mutableIntStateOf | import androidx.compose.runtime.mutableIntStateOf | ||||||
| import androidx.compose.runtime.mutableStateListOf | import androidx.compose.runtime.mutableStateListOf | ||||||
| import androidx.compose.runtime.mutableStateMapOf |  | ||||||
| import androidx.compose.runtime.mutableStateOf | import androidx.compose.runtime.mutableStateOf | ||||||
| import androidx.compose.runtime.snapshots.SnapshotStateList |  | ||||||
| import androidx.compose.ui.platform.LocalContext |  | ||||||
| import androidx.lifecycle.ViewModel | import androidx.lifecycle.ViewModel | ||||||
| import androidx.lifecycle.viewModelScope | import androidx.lifecycle.viewModelScope | ||||||
| import coil3.ImageLoader | import coil3.ImageLoader | ||||||
| import coil3.network.okhttp.OkHttpNetworkFetcherFactory | import coil3.network.okhttp.OkHttpNetworkFetcherFactory | ||||||
| import com.acitelight.aether.Global | import com.acitelight.aether.Global | ||||||
| import com.acitelight.aether.model.Video | import com.acitelight.aether.model.Video | ||||||
| import com.acitelight.aether.service.ApiClient.createOkHttp | import com.acitelight.aether.service.ApiClient | ||||||
| import com.acitelight.aether.service.FetchManager | import com.acitelight.aether.service.FetchManager | ||||||
| import com.acitelight.aether.service.MediaManager | import com.acitelight.aether.service.MediaManager | ||||||
| import com.acitelight.aether.service.RecentManager |  | ||||||
| import com.acitelight.aether.service.VideoLibrary | import com.acitelight.aether.service.VideoLibrary | ||||||
| import com.tonyodev.fetch2.Status | import com.tonyodev.fetch2.Status | ||||||
| import dagger.hilt.android.lifecycle.HiltViewModel | import dagger.hilt.android.lifecycle.HiltViewModel | ||||||
| @@ -31,20 +25,19 @@ import kotlinx.coroutines.launch | |||||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||||
| import java.io.File | import java.io.File | ||||||
| import javax.inject.Inject | import javax.inject.Inject | ||||||
| import javax.inject.Singleton |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @HiltViewModel | @HiltViewModel | ||||||
| class VideoScreenViewModel @Inject constructor( | class VideoScreenViewModel @Inject constructor( | ||||||
|     private val fetchManager: FetchManager, |     val fetchManager: FetchManager, | ||||||
|     @ApplicationContext val context: Context, |     @ApplicationContext val context: Context, | ||||||
|     val mediaManager: MediaManager, |     val mediaManager: MediaManager, | ||||||
|     val recentManager: RecentManager, |     val videoLibrary: VideoLibrary, | ||||||
|     val videoLibrary: VideoLibrary |     val apiClient: ApiClient | ||||||
| ) : ViewModel() { | ) : ViewModel() { | ||||||
|     private val _tabIndex = mutableIntStateOf(0) |     private val _tabIndex = mutableIntStateOf(0) | ||||||
|     val tabIndex: State<Int> = _tabIndex |     val tabIndex: State<Int> = _tabIndex | ||||||
|     var imageLoader: ImageLoader? = null; |     var imageLoader: ImageLoader? = null | ||||||
|     var menuVisibility = mutableStateOf(false) |     var menuVisibility = mutableStateOf(false) | ||||||
|     var searchFilter = mutableStateOf("") |     var searchFilter = mutableStateOf("") | ||||||
|     var doneInit = mutableStateOf(false) |     var doneInit = mutableStateOf(false) | ||||||
| @@ -53,14 +46,18 @@ class VideoScreenViewModel @Inject constructor( | |||||||
|         fetchManager.configured.filter { it }.first() |         fetchManager.configured.filter { it }.first() | ||||||
|  |  | ||||||
|         if (Global.loggedIn) { |         if (Global.loggedIn) { | ||||||
|             videoLibrary.classes.addAll(mediaManager.listVideoKlasses()) |             videoLibrary.classes.addAll( | ||||||
|  |                 mediaManager.listVideoKlasses().filter { it !in videoLibrary.classes } | ||||||
|  |             ) | ||||||
|  |  | ||||||
|             if(videoLibrary.classes.isEmpty()) |             if(videoLibrary.classes.isEmpty()) | ||||||
|                 return |                 return | ||||||
|  |  | ||||||
|             var i = 0 |             var i = 0 | ||||||
|             for (it in videoLibrary.classes) { |             for (it in videoLibrary.classes) { | ||||||
|                 videoLibrary.updatingMap[i++] = false |                 videoLibrary.updatingMap[i++] = false | ||||||
|                 videoLibrary.classesMap[it] = mutableStateListOf<Video>() |                 if(!videoLibrary.classesMap.containsKey(it)) | ||||||
|  |                     videoLibrary.classesMap[it] = mutableStateListOf() | ||||||
|             } |             } | ||||||
|             videoLibrary.updatingMap[0] = true |             videoLibrary.updatingMap[0] = true | ||||||
|             val vl = |             val vl = | ||||||
| @@ -68,16 +65,20 @@ class VideoScreenViewModel @Inject constructor( | |||||||
|  |  | ||||||
|             if (vl != null) { |             if (vl != null) { | ||||||
|                 val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name }) |                 val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name }) | ||||||
|                 videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(r) |                 val existsId = videoLibrary.classesMap[videoLibrary.classes[0]]?.map { it.id } | ||||||
|  |  | ||||||
|  |                 videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(r.filter { existsId == null || it.id !in existsId }) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|         else { |         else { | ||||||
|             videoLibrary.classes.add("Offline") |             videoLibrary.classes.add("Offline") | ||||||
|             videoLibrary.updatingMap[0] = true |             videoLibrary.updatingMap[0] = true | ||||||
|             videoLibrary.classesMap["Offline"] = mutableStateListOf<Video>() |             videoLibrary.classesMap["Offline"] = mutableStateListOf() | ||||||
|  |  | ||||||
|             val downloaded = fetchManager.getAllDownloadsAsync().filter { |             val downloaded = fetchManager.getAllDownloadsAsync().filter { | ||||||
|                 it.status == Status.COMPLETED && it.extras.getString("isComic", "") != "true" |                 it.status == Status.COMPLETED && | ||||||
|  |                 it.extras.getString("class", "") != "comic" && | ||||||
|  |                 it.extras.getString("type", "") == "main" | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             val jsonQuery = downloaded.map{ File( |             val jsonQuery = downloaded.map{ File( | ||||||
| @@ -94,7 +95,7 @@ class VideoScreenViewModel @Inject constructor( | |||||||
|     fun setTabIndex(index: Int) { |     fun setTabIndex(index: Int) { | ||||||
|         viewModelScope.launch() |         viewModelScope.launch() | ||||||
|         { |         { | ||||||
|             _tabIndex.intValue = index; |             _tabIndex.intValue = index | ||||||
|             if (videoLibrary.updatingMap[index] == true) return@launch |             if (videoLibrary.updatingMap[index] == true) return@launch | ||||||
|  |  | ||||||
|             videoLibrary.updatingMap[index] = true |             videoLibrary.updatingMap[index] = true | ||||||
| @@ -106,7 +107,8 @@ class VideoScreenViewModel @Inject constructor( | |||||||
|  |  | ||||||
|             if (vl != null) { |             if (vl != null) { | ||||||
|                 val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name }) |                 val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name }) | ||||||
|                 videoLibrary.classesMap[videoLibrary.classes[index]]?.addAll(r) |                 val existsId = videoLibrary.classesMap[videoLibrary.classes[index]]?.map { it.id } | ||||||
|  |                 videoLibrary.classesMap[videoLibrary.classes[index]]?.addAll(r.filter { existsId == null || it.id !in existsId }) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -118,7 +120,7 @@ class VideoScreenViewModel @Inject constructor( | |||||||
|     init { |     init { | ||||||
|         imageLoader = ImageLoader.Builder(context) |         imageLoader = ImageLoader.Builder(context) | ||||||
|             .components { |             .components { | ||||||
|                 add(OkHttpNetworkFetcherFactory(createOkHttp())) |                 add(OkHttpNetworkFetcherFactory(apiClient.getClient())) | ||||||
|             } |             } | ||||||
|             .build() |             .build() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| [versions] | [versions] | ||||||
|  | accompanistNavigationAnimation = "0.37.3" | ||||||
| agp = "8.13.0" | agp = "8.13.0" | ||||||
| ariaCompiler = "latest" | ariaCompiler = "latest" | ||||||
| bcprovJdk15on = "1.70" | bcprovJdk15on = "1.70" | ||||||
| @@ -10,35 +11,40 @@ datastorePreferences = "1.1.7" | |||||||
| exoplayerplus = "0.2.0" | exoplayerplus = "0.2.0" | ||||||
| fetch2 = "3.4.1" | fetch2 = "3.4.1" | ||||||
| fetch2okhttp = "3.4.1" | fetch2okhttp = "3.4.1" | ||||||
| gson = "2.13.1" | gson = "2.13.2" | ||||||
| kotlin = "2.2.20" | kotlin = "2.2.20" | ||||||
| coreKtx = "1.17.0" | coreKtx = "1.17.0" | ||||||
| junit = "4.13.2" | junit = "4.13.2" | ||||||
| junitVersion = "1.3.0" | junitVersion = "1.3.0" | ||||||
| espressoCore = "3.7.0" | espressoCore = "3.7.0" | ||||||
| kotlinxSerializationJson = "1.9.0" | kotlinxSerializationJson = "1.9.0" | ||||||
| lifecycleRuntimeKtx = "2.9.3" | lifecycleRuntimeKtx = "2.9.4" | ||||||
| activityCompose = "1.11.0" | activityCompose = "1.11.0" | ||||||
| composeBom = "2025.09.00" | composeBom = "2025.09.01" | ||||||
| media3Common = "1.8.0" | media3Common = "1.8.0" | ||||||
| media3Exoplayer = "1.8.0" | media3Exoplayer = "1.8.0" | ||||||
| media3ExoplayerFfmpeg = "1.8.0" | media3ExoplayerFfmpeg = "1.8.0" | ||||||
| media3Ui = "1.8.0" | media3Ui = "1.8.0" | ||||||
| navigationCompose = "2.9.4" | navigationCompose = "2.9.5" | ||||||
| okhttp = "5.1.0" | okhttp = "5.1.0" | ||||||
|  | persistentcookiejar = "1.0.1" | ||||||
|  | repo = "Tag" | ||||||
| retrofit = "3.0.0" | retrofit = "3.0.0" | ||||||
| retrofit2KotlinxSerializationConverter = "1.0.0" | retrofit2KotlinxSerializationConverter = "1.0.0" | ||||||
| media3DatasourceOkhttp = "1.8.0" | media3DatasourceOkhttp = "1.8.0" | ||||||
| roomCompiler = "2.8.0" | roomCompiler = "2.8.1" | ||||||
| roomKtx = "2.8.0" | roomKtx = "2.8.1" | ||||||
| roomRuntime = "2.8.0" | roomRuntime = "2.8.1" | ||||||
|  |  | ||||||
| ksp = "2.1.21-2.0.2" | ksp = "2.1.21-2.0.2" | ||||||
| hilt = "2.57.1" | hilt = "2.57.2" | ||||||
| hilt-navigation-compose = "1.3.0" | hilt-navigation-compose = "1.3.0" | ||||||
| composeMaterialCore = "1.5.1" | composeMaterialCore = "1.5.2" | ||||||
|  | constraintlayout = "2.2.1" | ||||||
|  | animation = "1.9.2" | ||||||
|  |  | ||||||
| [libraries] | [libraries] | ||||||
|  | accompanist-navigation-animation = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanistNavigationAnimation" } | ||||||
| androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } | ||||||
| androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } | androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } | ||||||
| androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } | androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } | ||||||
| @@ -72,6 +78,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit | |||||||
| androidx-material3 = { group = "androidx.compose.material3", name = "material3" } | androidx-material3 = { group = "androidx.compose.material3", name = "material3" } | ||||||
| kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } | kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } | ||||||
| okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } | okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } | ||||||
|  | persistentcookiejar = { module = "com.github.franmontiel:PersistentCookieJar", version.ref = "persistentcookiejar" } | ||||||
| retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } | retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } | ||||||
| retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" } | retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" } | ||||||
| androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" } | androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" } | ||||||
| @@ -80,6 +87,8 @@ hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref | |||||||
| hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", 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" } | hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" } | ||||||
| androidx-compose-material-core = { group = "androidx.wear.compose", name = "compose-material-core", version.ref = "composeMaterialCore" } | androidx-compose-material-core = { group = "androidx.wear.compose", name = "compose-material-core", version.ref = "composeMaterialCore" } | ||||||
|  | androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } | ||||||
|  | androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" } | ||||||
|  |  | ||||||
| [plugins] | [plugins] | ||||||
| android-application = { id = "com.android.application", version.ref = "agp" } | android-application = { id = "com.android.application", version.ref = "agp" } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user