Compare commits
44 Commits
dev-feat2
...
5b770a965d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b770a965d | ||
|
|
a89f892306 | ||
|
|
e38d77b2f6 | ||
|
|
756c2ea9f8 | ||
|
|
c9c3306766 | ||
|
|
49751c55d9 | ||
|
|
d918508c16 | ||
|
|
d858cd18bd | ||
|
|
82f537038c | ||
|
|
a298cb75e2 | ||
|
|
92f0e8543e | ||
|
|
f78bcc83c9 | ||
|
|
55ea2e1ae3 | ||
|
|
947ffc4599 | ||
|
|
1b24312a95 | ||
|
|
a15325deeb | ||
|
|
c402e18206 | ||
|
|
2260f26d9a | ||
|
|
829804abee | ||
|
|
e94249aa8f | ||
|
|
ad51c5da2f | ||
|
|
e6788d801a | ||
|
|
54c9d326c6 | ||
|
|
f7701cc85b | ||
|
|
cc540903d3 | ||
|
|
9c04d7679c | ||
|
|
c330a1e70c | ||
|
|
ffa70d9d34 | ||
|
|
7d07f19440 | ||
|
|
b4e73c4212 | ||
|
|
d28804178e | ||
|
|
10f316cb48 | ||
|
|
b48f8ce6b0 | ||
|
|
f6583ffcf1 | ||
|
|
aacd226260 | ||
|
|
514e99d7db | ||
|
|
18d021a8e5 | ||
|
|
daa66a9ecc | ||
|
|
ea574895ab | ||
|
|
06ada999c3 | ||
|
|
e249ae27c9 | ||
|
|
1a301770e2 | ||
|
|
0067f3000b | ||
|
|
55fda08e06 |
17
README.md
@@ -6,12 +6,9 @@
|
|||||||
|
|
||||||
_🚀This is the client of the multimedia server Abyss, which can also be extended to other purposes🚀_
|
_🚀This is the client of the multimedia server Abyss, which can also be extended to other purposes🚀_
|
||||||
|
|
||||||
|
<img src="aether_clip.png" width="25%" alt="Logo">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
<br/>
|
|
||||||
|
|
||||||
## Development background
|
## Development background
|
||||||
|
|
||||||
- Operating System: Voidraw OS v1.1 (based on Ubuntu) or any compatible Linux distribution.
|
- Operating System: Voidraw OS v1.1 (based on Ubuntu) or any compatible Linux distribution.
|
||||||
@@ -26,15 +23,15 @@ _🚀This is the client of the multimedia server Abyss, which can also be extend
|
|||||||
- [x] Hide private key after user input
|
- [x] Hide private key after user input
|
||||||
- [x] Optimize API call logic, do not create crashes
|
- [x] Optimize API call logic, do not create crashes
|
||||||
- [x] Fix the issue of freezing when entering the client without configuring the private key
|
- [x] Fix the issue of freezing when entering the client without configuring the private key
|
||||||
- [ ] Replace Android robot icon with custom design
|
- [x] Replace Android robot icon with custom design
|
||||||
- [ ] Configure server baseURL in client settings
|
- [x] Configure server baseURL in client settings
|
||||||
- [ ] Implement proper access control for directory queries
|
- [ ] Implement proper access control for directory queries
|
||||||
|
|
||||||
### Medium Priority
|
### Medium Priority
|
||||||
- [ ] Increase minHeight for video playback
|
- [x] Increase minHeight for video playback
|
||||||
- [ ] Add top bar with title and back button in full-screen mode
|
- [x] Add top bar with title and back button in full-screen mode
|
||||||
- [ ] Optimize data transfer system
|
- [x] Optimize data transfer system
|
||||||
- [ ] Improve manga/comic page display
|
- [x] Improve manga/comic page display
|
||||||
|
|
||||||
### Future
|
### Future
|
||||||
- [ ] (Prospective) Implement search functionality
|
- [ ] (Prospective) Implement search functionality
|
||||||
BIN
aether.png
Executable file
|
After Width: | Height: | Size: 616 KiB |
BIN
aether_clip.png
Normal file
|
After Width: | Height: | Size: 348 KiB |
@@ -3,6 +3,9 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
kotlin("plugin.serialization") version "1.9.0"
|
kotlin("plugin.serialization") version "1.9.0"
|
||||||
|
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.hilt.android)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -30,11 +33,12 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
}
|
}
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = "11"
|
jvmTarget = "21"
|
||||||
|
freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode")
|
||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
@@ -42,10 +46,23 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(libs.fetch2)
|
||||||
|
implementation(libs.fetch2okhttp)
|
||||||
|
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
implementation(libs.hilt.navigation.compose)
|
||||||
|
implementation(libs.androidx.compose.material.core)
|
||||||
|
ksp(libs.hilt.android.compiler)
|
||||||
|
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.bcprov.jdk15on)
|
implementation(libs.bcprov.jdk15on)
|
||||||
implementation(libs.converter.gson)
|
implementation(libs.converter.gson)
|
||||||
implementation(libs.gson)
|
implementation(libs.gson)
|
||||||
|
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.ui)
|
implementation(libs.androidx.media3.ui)
|
||||||
implementation(libs.androidx.media3.common)
|
implementation(libs.androidx.media3.common)
|
||||||
|
|||||||
@@ -12,22 +12,31 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/aether"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/aether_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Aether"
|
android:theme="@style/Theme.Aether"
|
||||||
android:name=".AetherApp">
|
android:name=".AetherApp">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
|
||||||
android:theme="@style/Theme.Aether">
|
android:theme="@style/Theme.Aether">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainScreenActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.Aether">
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".AbyssService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
BIN
app/src/main/aether-playstore.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
49
app/src/main/java/com/acitelight/aether/AbyssService.kt
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package com.acitelight.aether
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.IBinder
|
||||||
|
import com.acitelight.aether.service.AbyssTunnelProxy
|
||||||
|
import com.acitelight.aether.service.FetchManager
|
||||||
|
import com.acitelight.aether.service.SettingsDataStoreManager
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class AbyssService: Service() {
|
||||||
|
@Inject
|
||||||
|
lateinit var proxy: AbyssTunnelProxy
|
||||||
|
@Inject
|
||||||
|
lateinit var downloader: FetchManager
|
||||||
|
|
||||||
|
private val binder = AbyssServiceBinder()
|
||||||
|
private val _isInitialized = MutableStateFlow(false)
|
||||||
|
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.IO + Job())
|
||||||
|
|
||||||
|
inner class AbyssServiceBinder : Binder() {
|
||||||
|
fun getService(): AbyssService = this@AbyssService
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder {
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
serviceScope.launch {
|
||||||
|
_isInitialized.update { true }
|
||||||
|
proxy.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,51 @@
|
|||||||
package com.acitelight.aether
|
package com.acitelight.aether
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "configure")
|
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "configure")
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
class AetherApp : Application() {
|
class AetherApp : Application() {
|
||||||
|
var abyssService: AbyssService? = null
|
||||||
|
var isServiceBound = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
val isServiceInitialized: StateFlow<Boolean>?
|
||||||
|
get() = abyssService?.isInitialized
|
||||||
|
|
||||||
|
private val serviceConnection = object : ServiceConnection {
|
||||||
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
val binder = service as AbyssService.AbyssServiceBinder
|
||||||
|
abyssService = binder.getService()
|
||||||
|
isServiceBound = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
|
isServiceBound = false
|
||||||
|
abyssService = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
||||||
|
val intent = Intent(this, AbyssService::class.java)
|
||||||
|
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,17 @@ import com.acitelight.aether.model.Video
|
|||||||
object Global {
|
object Global {
|
||||||
var loggedIn by mutableStateOf(false)
|
var loggedIn by mutableStateOf(false)
|
||||||
var sameClassVideos: List<Video>? = null
|
var sameClassVideos: List<Video>? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun updateRelate(v: List<Video>, s: Video)
|
||||||
|
{
|
||||||
|
sameClassVideos = if (v.contains(s)) {
|
||||||
|
val index = v.indexOf(s)
|
||||||
|
val afterS = v.subList(index, v.size)
|
||||||
|
val beforeS = v.subList(0, index)
|
||||||
|
afterS + beforeS
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.acitelight.aether
|
package com.acitelight.aether
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import android.graphics.drawable.Icon
|
import android.graphics.drawable.Icon
|
||||||
import android.net.http.SslCertificate.saveState
|
import android.net.http.SslCertificate.saveState
|
||||||
@@ -12,13 +13,22 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.automirrored.filled.CompareArrows
|
import androidx.compose.material.icons.automirrored.filled.CompareArrows
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardColors
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -31,6 +41,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
@@ -39,6 +50,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
@@ -48,13 +60,41 @@ import androidx.navigation.compose.composable
|
|||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
|
import com.acitelight.aether.view.ComicGridView
|
||||||
|
import com.acitelight.aether.view.ComicPageView
|
||||||
import com.acitelight.aether.view.ComicScreen
|
import com.acitelight.aether.view.ComicScreen
|
||||||
import com.acitelight.aether.view.HomeScreen
|
import com.acitelight.aether.view.HomeScreen
|
||||||
import com.acitelight.aether.view.MeScreen
|
import com.acitelight.aether.view.MeScreen
|
||||||
|
import com.acitelight.aether.view.TransmissionScreen
|
||||||
import com.acitelight.aether.view.VideoPlayer
|
import com.acitelight.aether.view.VideoPlayer
|
||||||
import com.acitelight.aether.view.VideoScreen
|
import com.acitelight.aether.view.VideoScreen
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val app = application as AetherApp
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
app.isServiceInitialized?.filter { it }?.first()
|
||||||
|
val intent = Intent(this@MainActivity, MainScreenActivity::class.java)
|
||||||
|
startActivity(intent)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainScreenActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
window.attributes = window.attributes.apply {
|
window.attributes = window.attributes.apply {
|
||||||
@@ -98,6 +138,8 @@ fun AppNavigation() {
|
|||||||
|
|
||||||
val hideBottomBarRoutes = listOf(
|
val hideBottomBarRoutes = listOf(
|
||||||
Screen.VideoPlayer.route,
|
Screen.VideoPlayer.route,
|
||||||
|
Screen.ComicGrid.route,
|
||||||
|
Screen.ComicPage.route
|
||||||
)
|
)
|
||||||
val shouldShowBottomBar = currentRoute !in hideBottomBarRoutes
|
val shouldShowBottomBar = currentRoute !in hideBottomBarRoutes
|
||||||
|
|
||||||
@@ -116,21 +158,27 @@ fun AppNavigation() {
|
|||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Screen.Home.route,
|
startDestination = Screen.Me.route,
|
||||||
modifier = if(shouldShowBottomBar)Modifier.padding(innerPadding) else Modifier.padding(0.dp)
|
modifier = if(shouldShowBottomBar)Modifier.padding(innerPadding) else Modifier.padding(0.dp)
|
||||||
) {
|
) {
|
||||||
composable(Screen.Home.route) {
|
composable(Screen.Home.route) {
|
||||||
HomeScreen(navController = navController)
|
CardPage(title = "Home") {
|
||||||
|
HomeScreen(navController = navController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Screen.Video.route) {
|
composable(Screen.Video.route) {
|
||||||
VideoScreen(navController = navController)
|
VideoScreen(navController = navController)
|
||||||
}
|
}
|
||||||
composable(Screen.Comic.route) {
|
composable(Screen.Comic.route) {
|
||||||
ComicScreen()
|
CardPage(title = "Comic") {
|
||||||
|
ComicScreen(navController = navController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(Screen.Transmission.route) {
|
composable(Screen.Transmission.route) {
|
||||||
// ComicScreen()
|
CardPage(title = "Tasks") {
|
||||||
|
TransmissionScreen(navigator = navController)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Screen.Me.route) {
|
composable(Screen.Me.route) {
|
||||||
MeScreen();
|
MeScreen();
|
||||||
@@ -146,6 +194,30 @@ fun AppNavigation() {
|
|||||||
VideoPlayer(videoId = videoId, navController = navController)
|
VideoPlayer(videoId = videoId, navController = navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.ComicGrid.route,
|
||||||
|
arguments = listOf(navArgument("comicId") { type = NavType.StringType })
|
||||||
|
) {
|
||||||
|
backStackEntry ->
|
||||||
|
val comicId = backStackEntry.arguments?.getString("comicId")
|
||||||
|
if (comicId != null) {
|
||||||
|
ComicGridView(comicId = comicId, navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(
|
||||||
|
route = Screen.ComicPage.route,
|
||||||
|
arguments = listOf(navArgument("comicId") { type = NavType.StringType }, navArgument("page") { type = NavType.StringType })
|
||||||
|
) {
|
||||||
|
backStackEntry ->
|
||||||
|
val comicId = backStackEntry.arguments?.getString("comicId")
|
||||||
|
val page = backStackEntry.arguments?.getString("page")
|
||||||
|
if (comicId != null && page != null) {
|
||||||
|
ComicPageView(comicId = comicId, page = page, navController = navController)
|
||||||
|
ToggleFullScreen(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +234,7 @@ fun BottomNavigationBar(navController: NavController) {
|
|||||||
Screen.Transmission,
|
Screen.Transmission,
|
||||||
Screen.Me
|
Screen.Me
|
||||||
) else listOf(
|
) else listOf(
|
||||||
Screen.Home,
|
Screen.Video,
|
||||||
Screen.Transmission,
|
Screen.Transmission,
|
||||||
Screen.Me
|
Screen.Me
|
||||||
)
|
)
|
||||||
@@ -187,6 +259,37 @@ fun BottomNavigationBar(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CardPage(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
Box(Modifier.background(if (isSystemInDarkTheme()) {
|
||||||
|
Color.Black
|
||||||
|
} else {
|
||||||
|
Color.White
|
||||||
|
}).fillMaxSize())
|
||||||
|
{
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(6.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(4.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.background)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sealed class Screen(val route: String, val icon: ImageVector, val title: String) {
|
sealed class Screen(val route: String, val icon: ImageVector, val title: String) {
|
||||||
data object Home : Screen("home_route", Icons.Filled.Home, "Home")
|
data object Home : Screen("home_route", Icons.Filled.Home, "Home")
|
||||||
data object Video : Screen("video_route", Icons.Filled.VideoLibrary, "Video")
|
data object Video : Screen("video_route", Icons.Filled.VideoLibrary, "Video")
|
||||||
@@ -195,4 +298,6 @@ sealed class Screen(val route: String, val icon: ImageVector, val title: String)
|
|||||||
Icons.AutoMirrored.Filled.CompareArrows, "Transmission")
|
Icons.AutoMirrored.Filled.CompareArrows, "Transmission")
|
||||||
data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me")
|
data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me")
|
||||||
data object VideoPlayer : Screen("video_player_route/{videoId}", Icons.Filled.PlayArrow, "VideoPlayer")
|
data object VideoPlayer : Screen("video_player_route/{videoId}", Icons.Filled.PlayArrow, "VideoPlayer")
|
||||||
|
data object ComicGrid : Screen("comic_grid_route/{comicId}", Icons.Filled.PlayArrow, "ComicGrid")
|
||||||
|
data object ComicPage : Screen("comic_page_route/{comicId}/{page}", Icons.Filled.PlayArrow, "ComicPage")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.acitelight.aether.helper
|
||||||
|
|
||||||
|
import com.acitelight.aether.model.Video
|
||||||
|
import java.text.Collator
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
fun MutableList<Video>.insertInNaturalOrder(n: Video) {
|
||||||
|
// Windows sorting is locale-sensitive. Use the default locale.
|
||||||
|
val collator = Collator.getInstance(Locale.getDefault())
|
||||||
|
|
||||||
|
val naturalComparator = Comparator<String> { s1, s2 ->
|
||||||
|
val naturalOrderComparator = fun(str1: String, str2: String): Int {
|
||||||
|
// Function to compare segments (numeric vs. non-numeric)
|
||||||
|
val compareSegments = fun(segment1: String, segment2: String, isNumeric: Boolean): Int {
|
||||||
|
return if (isNumeric) {
|
||||||
|
val num1 = segment1.toLongOrNull() ?: 0
|
||||||
|
val num2 = segment2.toLongOrNull() ?: 0
|
||||||
|
num1.compareTo(num2)
|
||||||
|
} else {
|
||||||
|
collator.compare(segment1, segment2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regex to split string into numeric and non-numeric parts
|
||||||
|
val regex = "(\\d+)|(\\D+)".toRegex()
|
||||||
|
val matches1 = regex.findAll(str1).toList()
|
||||||
|
val matches2 = regex.findAll(str2).toList()
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < matches1.size && i < matches2.size) {
|
||||||
|
val match1 = matches1[i]
|
||||||
|
val match2 = matches2[i]
|
||||||
|
|
||||||
|
val isNumeric1 = match1.groupValues[1].isNotEmpty()
|
||||||
|
val isNumeric2 = match2.groupValues[1].isNotEmpty()
|
||||||
|
|
||||||
|
when {
|
||||||
|
isNumeric1 && isNumeric2 -> {
|
||||||
|
val result = compareSegments(match1.value, match2.value, true)
|
||||||
|
if (result != 0) return result
|
||||||
|
}
|
||||||
|
!isNumeric1 && !isNumeric2 -> {
|
||||||
|
val result = compareSegments(match1.value, match2.value, false)
|
||||||
|
if (result != 0) return result
|
||||||
|
}
|
||||||
|
isNumeric1 -> return -1 // Numeric part comes before non-numeric
|
||||||
|
isNumeric2 -> return 1
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
// If one string is a prefix of the other, the shorter one comes first
|
||||||
|
return str1.length.compareTo(str2.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
naturalOrderComparator(s1, s2)
|
||||||
|
}
|
||||||
|
|
||||||
|
var inserted = false
|
||||||
|
|
||||||
|
// Find the correct insertion point
|
||||||
|
for (i in this.indices) {
|
||||||
|
if (naturalComparator.compare(n.video.name, this[i].video.name) <= 0) {
|
||||||
|
this.add(i, n)
|
||||||
|
inserted = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's the largest, add to the end
|
||||||
|
if (!inserted) {
|
||||||
|
this.add(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,69 @@
|
|||||||
package com.acitelight.aether.model
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
data class Comic(
|
import com.acitelight.aether.service.ApiClient
|
||||||
val comic_name: String,
|
|
||||||
val page_count: Int,
|
class Comic(
|
||||||
val bookmarks: List<BookMark>,
|
val comic: ComicResponse,
|
||||||
val pages: List<String>
|
val id: String,
|
||||||
)
|
val token: String
|
||||||
|
)
|
||||||
|
{
|
||||||
|
fun getPage(pageNumber: Int): String
|
||||||
|
{
|
||||||
|
return "${ApiClient.getBase()}api/image/$id/${comic.list[pageNumber]}?token=$token"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPage(pageName: String): String?
|
||||||
|
{
|
||||||
|
val v = comic.list.indexOf(pageName)
|
||||||
|
if(v >= 0)
|
||||||
|
{
|
||||||
|
return getPage(v)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPageIndex(pageName: String): Int
|
||||||
|
{
|
||||||
|
return comic.list.indexOf(pageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChapterLength(pageName: String): Int
|
||||||
|
{
|
||||||
|
var v = comic.list.indexOf(pageName)
|
||||||
|
if(v >= 0)
|
||||||
|
{
|
||||||
|
var r: Int = 1
|
||||||
|
v+=1
|
||||||
|
while(v < comic.list.size && !comic.bookmarks.any{
|
||||||
|
x -> x.page == comic.list[v]
|
||||||
|
}){
|
||||||
|
r++
|
||||||
|
v+=1
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPageChapterIndex(page: Int): Pair<BookMark, Int>
|
||||||
|
{
|
||||||
|
var p = page
|
||||||
|
while(p >= 0 && !comic.bookmarks.any{ x -> x.page == comic.list[p] })
|
||||||
|
{
|
||||||
|
p--
|
||||||
|
}
|
||||||
|
if(p < 0) return Pair(BookMark(name="null", page=comic.list[0]), page + 1)
|
||||||
|
for(i in comic.bookmarks)
|
||||||
|
{
|
||||||
|
if(i.page == comic.list[p])
|
||||||
|
{
|
||||||
|
return Pair(i, page - comic.list.indexOf(i.page) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Pair(BookMark(name="null", page=comic.list[0]), page + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/src/main/java/com/acitelight/aether/model/ComicRecord.kt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
data class ComicRecord(
|
||||||
|
@PrimaryKey(autoGenerate = false) val id: Int = 0,
|
||||||
|
@ColumnInfo(name = "name") val name: String,
|
||||||
|
@ColumnInfo(name = "position") val position: Int
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface ComicRecordDao {
|
||||||
|
@Query("SELECT * FROM comicrecord")
|
||||||
|
fun getAll(): Flow<List<ComicRecord>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(rec: ComicRecord)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(rec: ComicRecord)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun delete(rec: ComicRecord)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM comicrecord WHERE id = :id")
|
||||||
|
suspend fun getById(id: Int): ComicRecord?
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
|
|
||||||
|
@Database(entities = [ComicRecord::class], version = 1)
|
||||||
|
abstract class ComicRecordDatabase : RoomDatabase() {
|
||||||
|
abstract fun userDao(): ComicRecordDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: ComicRecordDatabase? = null
|
||||||
|
|
||||||
|
fun getDatabase(context: Context): ComicRecordDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
ComicRecordDatabase::class.java,
|
||||||
|
"comicrecord_database"
|
||||||
|
).build()
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
|
data class ComicResponse(
|
||||||
|
val comic_name: String,
|
||||||
|
val page_count: Int,
|
||||||
|
val bookmarks: List<BookMark>,
|
||||||
|
val list: List<String>,
|
||||||
|
val tags: List<String>,
|
||||||
|
val author: String
|
||||||
|
)
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package com.acitelight.aether.model
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class Comment(
|
data class Comment(
|
||||||
val content: String,
|
val content: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.acitelight.aether.model
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
data class KeyImage(
|
data class KeyImage(
|
||||||
|
val name: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
val key: String
|
val key: String
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,29 +1,66 @@
|
|||||||
package com.acitelight.aether.model
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
import com.acitelight.aether.service.ApiClient
|
import com.acitelight.aether.service.ApiClient
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import java.security.KeyPair
|
import java.security.KeyPair
|
||||||
|
|
||||||
class Video constructor(
|
|
||||||
|
@Serializable
|
||||||
|
class Video(
|
||||||
|
val isLocal: Boolean,
|
||||||
|
val localBase: String,
|
||||||
val klass: String,
|
val klass: String,
|
||||||
val id: String,
|
val id: String,
|
||||||
val token: String,
|
val token: String,
|
||||||
val video: VideoResponse
|
val video: VideoResponse
|
||||||
){
|
) {
|
||||||
fun getCover(): String
|
fun getCover(): String {
|
||||||
{
|
return if (isLocal)
|
||||||
return "${ApiClient.base}api/video/$klass/$id/cover?token=$token"
|
"$localBase/videos/$klass/$id/cover.jpg"
|
||||||
|
else
|
||||||
|
"${ApiClient.getBase()}api/video/$klass/$id/cover?token=$token"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getVideo(): String
|
fun getVideo(): String {
|
||||||
{
|
return if (isLocal)
|
||||||
return "${ApiClient.base}api/video/$klass/$id/av?token=$token"
|
"$localBase/videos/$klass/$id/video.mp4"
|
||||||
|
else
|
||||||
|
"${ApiClient.getBase()}api/video/$klass/$id/av?token=$token"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getGallery(): List<KeyImage>
|
fun getSubtitle(): String {
|
||||||
{
|
return if (isLocal)
|
||||||
return video.gallery.map{
|
"$localBase/videos/$klass/$id/subtitle.vtt"
|
||||||
KeyImage(url = "${ApiClient.base}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it")
|
else
|
||||||
|
"${ApiClient.getBase()}api/video/$klass/$id/subtitle?token=$token"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGallery(): List<KeyImage> {
|
||||||
|
return if (isLocal)
|
||||||
|
video.gallery.map {
|
||||||
|
KeyImage(
|
||||||
|
name = it,
|
||||||
|
url = "$localBase/videos/$klass/$id/gallery/$it",
|
||||||
|
key = "$klass/$id/gallery/$it"
|
||||||
|
)
|
||||||
|
} else video.gallery.map {
|
||||||
|
KeyImage(
|
||||||
|
name = it,
|
||||||
|
url = "${ApiClient.getBase()}api/video/$klass/$id/gallery/$it?token=$token",
|
||||||
|
key = "$klass/$id/gallery/$it"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun toLocal(localBase: String): Video
|
||||||
|
{
|
||||||
|
return Video(
|
||||||
|
isLocal = true,
|
||||||
|
localBase = localBase,
|
||||||
|
klass = klass,
|
||||||
|
id = id,
|
||||||
|
token = "",
|
||||||
|
video = video
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import com.tonyodev.fetch2.Status
|
||||||
|
|
||||||
|
class VideoDownloadItemState(
|
||||||
|
val id: Int,
|
||||||
|
fileName: String,
|
||||||
|
filePath: String,
|
||||||
|
url: String,
|
||||||
|
progress: Int,
|
||||||
|
status: Status,
|
||||||
|
downloadedBytes: Long,
|
||||||
|
totalBytes: Long,
|
||||||
|
klass: String,
|
||||||
|
vid: String,
|
||||||
|
val type: String
|
||||||
|
) {
|
||||||
|
var fileName by mutableStateOf(fileName)
|
||||||
|
var filePath by mutableStateOf(filePath)
|
||||||
|
var url by mutableStateOf(url)
|
||||||
|
var progress by mutableStateOf(progress)
|
||||||
|
var status by mutableStateOf(status)
|
||||||
|
var downloadedBytes by mutableStateOf(downloadedBytes)
|
||||||
|
var totalBytes by mutableStateOf(totalBytes)
|
||||||
|
|
||||||
|
var klass by mutableStateOf(klass)
|
||||||
|
var vid by mutableStateOf(vid)
|
||||||
|
}
|
||||||
14
app/src/main/java/com/acitelight/aether/model/VideoRecord.kt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
data class VideoRecord (
|
||||||
|
@PrimaryKey(autoGenerate = false) val id: String = "",
|
||||||
|
@ColumnInfo(name = "name") val klass: String = "",
|
||||||
|
@ColumnInfo(name = "position") val position: Long,
|
||||||
|
@ColumnInfo(name = "time") val time: Long,
|
||||||
|
@ColumnInfo(name = "group") val group: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface VideoRecordDao {
|
||||||
|
@Query("SELECT * FROM videorecord")
|
||||||
|
fun getAll(): Flow<List<VideoRecord>>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(rec: VideoRecord)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(rec: VideoRecord)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun delete(rec: VideoRecord)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM videorecord WHERE id = :id and name = :klass")
|
||||||
|
suspend fun get(id: String, klass: String): VideoRecord?
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
|
@Database(entities = [VideoRecord::class], version = 1)
|
||||||
|
abstract class VideoRecordDatabase : RoomDatabase() {
|
||||||
|
abstract fun userDao(): VideoRecordDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: VideoRecordDatabase? = null
|
||||||
|
|
||||||
|
fun getDatabase(context: Context): VideoRecordDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
val instance = Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
VideoRecordDatabase::class.java,
|
||||||
|
"videorecords_database"
|
||||||
|
).build()
|
||||||
|
INSTANCE = instance
|
||||||
|
instance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.acitelight.aether.model
|
package com.acitelight.aether.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class VideoResponse(
|
data class VideoResponse(
|
||||||
val name: String,
|
val name: String,
|
||||||
val duration: Long,
|
val duration: Long,
|
||||||
@@ -7,5 +10,6 @@ data class VideoResponse(
|
|||||||
val comment: List<Comment>,
|
val comment: List<Comment>,
|
||||||
val star: Boolean,
|
val star: Boolean,
|
||||||
val like: Int,
|
val like: Int,
|
||||||
val author: String
|
val author: String,
|
||||||
|
val group: String?
|
||||||
)
|
)
|
||||||
|
|||||||
371
app/src/main/java/com/acitelight/aether/service/AbyssStream.kt
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
package com.acitelight.aether.service
|
||||||
|
|
||||||
|
import com.acitelight.aether.service.AuthManager.db64
|
||||||
|
import com.acitelight.aether.service.AuthManager.signChallenge
|
||||||
|
import com.acitelight.aether.service.AuthManager.signChallengeByte
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
|
||||||
|
import java.net.Socket
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.util.ArrayDeque
|
||||||
|
|
||||||
|
import org.bouncycastle.math.ec.rfc7748.X25519
|
||||||
|
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
|
||||||
|
import org.bouncycastle.crypto.digests.SHA256Digest
|
||||||
|
import org.bouncycastle.crypto.params.HKDFParameters
|
||||||
|
import org.bouncycastle.crypto.params.KeyParameter
|
||||||
|
import org.bouncycastle.crypto.params.AEADParameters
|
||||||
|
import org.bouncycastle.crypto.modes.ChaCha20Poly1305
|
||||||
|
import java.io.EOFException
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
class AbyssStream private constructor(
|
||||||
|
private val socket: Socket,
|
||||||
|
private val input: InputStream,
|
||||||
|
private val output: OutputStream,
|
||||||
|
private val aeadKey: ByteArray,
|
||||||
|
private val sendSalt: ByteArray,
|
||||||
|
private val recvSalt: ByteArray
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val PUBLIC_KEY_LEN = 32
|
||||||
|
private const val AEAD_KEY_LEN = 32
|
||||||
|
private const val NONCE_SALT_LEN = 4
|
||||||
|
private const val AEAD_TAG_LEN = 16
|
||||||
|
private const val NONCE_LEN = 12
|
||||||
|
private const val MAX_PLAINTEXT_FRAME = 64 * 1024
|
||||||
|
|
||||||
|
private val secureRandom = SecureRandom()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and perform handshake on an already-connected socket.
|
||||||
|
* If privateKeyRaw is provided, it must be 32 bytes.
|
||||||
|
*/
|
||||||
|
suspend fun create(socket: Socket, privateKeyRaw: ByteArray? = null): AbyssStream = withContext(Dispatchers.IO) {
|
||||||
|
if (!socket.isConnected) throw IllegalArgumentException("socket is not connected")
|
||||||
|
val inStream = socket.getInputStream()
|
||||||
|
val outStream = socket.getOutputStream()
|
||||||
|
|
||||||
|
// 1) keypair (raw)
|
||||||
|
val localPriv = ByteArray(PUBLIC_KEY_LEN)
|
||||||
|
if (privateKeyRaw != null) {
|
||||||
|
if (privateKeyRaw.size != PUBLIC_KEY_LEN) {
|
||||||
|
throw IllegalArgumentException("privateKeyRaw must be $PUBLIC_KEY_LEN bytes")
|
||||||
|
}
|
||||||
|
System.arraycopy(privateKeyRaw, 0, localPriv, 0, PUBLIC_KEY_LEN)
|
||||||
|
} else {
|
||||||
|
X25519.generatePrivateKey(secureRandom, localPriv)
|
||||||
|
}
|
||||||
|
val localPub = ByteArray(PUBLIC_KEY_LEN)
|
||||||
|
X25519.scalarMultBase(localPriv, 0, localPub, 0)
|
||||||
|
|
||||||
|
// 2) exchange raw public keys (exact 32 bytes each) using blocking IO
|
||||||
|
writeExact(outStream, localPub, 0, PUBLIC_KEY_LEN)
|
||||||
|
val remotePub = ByteArray(PUBLIC_KEY_LEN)
|
||||||
|
readExact(inStream, remotePub, 0, PUBLIC_KEY_LEN)
|
||||||
|
|
||||||
|
val ch = ByteArray(32)
|
||||||
|
readExact(inStream, ch, 0, 32)
|
||||||
|
val signed = signChallengeByte(localPriv, ch)
|
||||||
|
writeExact(outStream, signed, 0, signed.size)
|
||||||
|
readExact(inStream, ch, 0, 16)
|
||||||
|
|
||||||
|
// 3) compute shared secret: X25519.scalarMult(private, remotePublic)
|
||||||
|
val shared = ByteArray(PUBLIC_KEY_LEN)
|
||||||
|
X25519.scalarMult(localPriv, 0, remotePub, 0, shared, 0)
|
||||||
|
|
||||||
|
// 4) HKDF-SHA256 -> AEAD key + saltA + saltB
|
||||||
|
val hkdf = HKDFBytesGenerator(SHA256Digest())
|
||||||
|
// AEAD key
|
||||||
|
hkdf.init(HKDFParameters(shared, null, "Abyss-AEAD-Key".toByteArray(Charsets.US_ASCII)))
|
||||||
|
val aeadKey = ByteArray(AEAD_KEY_LEN)
|
||||||
|
hkdf.generateBytes(aeadKey, 0, AEAD_KEY_LEN)
|
||||||
|
|
||||||
|
// salt A
|
||||||
|
hkdf.init(HKDFParameters(shared, null, "Abyss-Nonce-Salt-A".toByteArray(Charsets.US_ASCII)))
|
||||||
|
val saltA = ByteArray(NONCE_SALT_LEN)
|
||||||
|
hkdf.generateBytes(saltA, 0, NONCE_SALT_LEN)
|
||||||
|
|
||||||
|
// salt B
|
||||||
|
hkdf.init(HKDFParameters(shared, null, "Abyss-Nonce-Salt-B".toByteArray(Charsets.US_ASCII)))
|
||||||
|
val saltB = ByteArray(NONCE_SALT_LEN)
|
||||||
|
hkdf.generateBytes(saltB, 0, NONCE_SALT_LEN)
|
||||||
|
|
||||||
|
// Deterministic assignment by lexicographic comparison
|
||||||
|
val cmp = lexicographicCompare(localPub, remotePub)
|
||||||
|
val sendSalt: ByteArray
|
||||||
|
val recvSalt: ByteArray
|
||||||
|
if (cmp < 0) {
|
||||||
|
sendSalt = saltA
|
||||||
|
recvSalt = saltB
|
||||||
|
} else if (cmp > 0) {
|
||||||
|
sendSalt = saltB
|
||||||
|
recvSalt = saltA
|
||||||
|
} else {
|
||||||
|
// extremely unlikely
|
||||||
|
sendSalt = saltA
|
||||||
|
recvSalt = saltB
|
||||||
|
}
|
||||||
|
|
||||||
|
// zero sensitive buffers
|
||||||
|
localPriv.fill(0)
|
||||||
|
localPub.fill(0)
|
||||||
|
remotePub.fill(0)
|
||||||
|
shared.fill(0)
|
||||||
|
// keep aeadKey, sendSalt, recvSalt
|
||||||
|
|
||||||
|
return@withContext AbyssStream(socket, inStream, outStream, aeadKey, sendSalt, recvSalt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lexicographicCompare(a: ByteArray, b: ByteArray): Int {
|
||||||
|
val min = kotlin.math.min(a.size, b.size)
|
||||||
|
for (i in 0 until min) {
|
||||||
|
val av = a[i].toInt() and 0xff
|
||||||
|
val bv = b[i].toInt() and 0xff
|
||||||
|
if (av < bv) return -1
|
||||||
|
if (av > bv) return 1
|
||||||
|
}
|
||||||
|
if (a.size < b.size) return -1
|
||||||
|
if (a.size > b.size) return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readExact(input: InputStream, buffer: ByteArray, offset: Int, count: Int) {
|
||||||
|
var read = 0
|
||||||
|
while (read < count) {
|
||||||
|
val n = input.read(buffer, offset + read, count - read)
|
||||||
|
if (n == -1) {
|
||||||
|
if (read == 0) throw EOFException("Remote closed connection while reading")
|
||||||
|
else throw EOFException("Remote closed connection unexpectedly during read")
|
||||||
|
}
|
||||||
|
read += n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeExact(output: OutputStream, buffer: ByteArray, offset: Int, count: Int) {
|
||||||
|
output.write(buffer, offset, count)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// internal state
|
||||||
|
private val sendCounter = AtomicLong(0L)
|
||||||
|
private val recvCounter = AtomicLong(0L)
|
||||||
|
private val sendLock = Any()
|
||||||
|
private val aeadLock = Any()
|
||||||
|
|
||||||
|
// leftover read queue
|
||||||
|
private val leftoverQueue = ArrayDeque<ByteArray>()
|
||||||
|
private var currentLeftover: ByteArray? = null
|
||||||
|
private var currentLeftoverOffset = 0
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var closed = false
|
||||||
|
|
||||||
|
// ---- high-level read/write APIs (suspendable) ----
|
||||||
|
|
||||||
|
suspend fun read(buffer: ByteArray, offset: Int, count: Int): Int = withContext(Dispatchers.IO) {
|
||||||
|
if (closed) throw IllegalStateException("AbyssStream closed")
|
||||||
|
if (buffer.size < offset + count) throw IndexOutOfBoundsException()
|
||||||
|
// serve leftover first
|
||||||
|
if (ensureCurrentLeftover()) {
|
||||||
|
val seg = currentLeftover!!
|
||||||
|
val avail = seg.size - currentLeftoverOffset
|
||||||
|
val toCopy = kotlin.math.min(avail, count)
|
||||||
|
System.arraycopy(seg, currentLeftoverOffset, buffer, offset, toCopy)
|
||||||
|
currentLeftoverOffset += toCopy
|
||||||
|
if (currentLeftoverOffset >= seg.size) {
|
||||||
|
currentLeftover = null
|
||||||
|
currentLeftoverOffset = 0
|
||||||
|
}
|
||||||
|
return@withContext toCopy
|
||||||
|
}
|
||||||
|
|
||||||
|
// read one frame and decrypt
|
||||||
|
val plaintext = readOneFrameAndDecrypt()
|
||||||
|
if (plaintext == null || plaintext.isEmpty()) {
|
||||||
|
// EOF
|
||||||
|
return@withContext 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext if (plaintext.size <= count) {
|
||||||
|
System.arraycopy(plaintext, 0, buffer, offset, plaintext.size)
|
||||||
|
plaintext.size
|
||||||
|
} else {
|
||||||
|
System.arraycopy(plaintext, 0, buffer, offset, count)
|
||||||
|
val leftoverLen = plaintext.size - count
|
||||||
|
val leftover = ByteArray(leftoverLen)
|
||||||
|
System.arraycopy(plaintext, count, leftover, 0, leftoverLen)
|
||||||
|
synchronized(leftoverQueue) { leftoverQueue.addLast(leftover) }
|
||||||
|
count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureCurrentLeftover(): Boolean {
|
||||||
|
if (currentLeftover != null && currentLeftoverOffset < currentLeftover!!.size) return true
|
||||||
|
synchronized(leftoverQueue) {
|
||||||
|
val next = leftoverQueue.pollFirst()
|
||||||
|
if (next != null) {
|
||||||
|
currentLeftover = next
|
||||||
|
currentLeftoverOffset = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readOneFrameAndDecrypt(): ByteArray? {
|
||||||
|
// read 4-byte header
|
||||||
|
val header = ByteArray(4)
|
||||||
|
try {
|
||||||
|
readExact(input, header, 0, 4)
|
||||||
|
} catch (e: EOFException) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val payloadLen = ByteBuffer.wrap(header).int and 0xffffffff.toInt()
|
||||||
|
if (payloadLen > MAX_PLAINTEXT_FRAME + AEAD_TAG_LEN) throw IllegalStateException("payload too big")
|
||||||
|
if (payloadLen < AEAD_TAG_LEN) throw IllegalStateException("payload too small")
|
||||||
|
|
||||||
|
val payload = ByteArray(payloadLen)
|
||||||
|
readExact(input, payload, 0, payloadLen)
|
||||||
|
|
||||||
|
val ciphertextLen = payloadLen - AEAD_TAG_LEN
|
||||||
|
val ciphertext = ByteArray(ciphertextLen)
|
||||||
|
val tag = ByteArray(AEAD_TAG_LEN)
|
||||||
|
if (ciphertextLen > 0) System.arraycopy(payload, 0, ciphertext, 0, ciphertextLen)
|
||||||
|
System.arraycopy(payload, ciphertextLen, tag, 0, AEAD_TAG_LEN)
|
||||||
|
|
||||||
|
val remoteCounterValue = recvCounter.getAndIncrement()
|
||||||
|
|
||||||
|
val nonce = ByteArray(NONCE_LEN)
|
||||||
|
System.arraycopy(recvSalt, 0, nonce, 0, NONCE_SALT_LEN)
|
||||||
|
// write 8-byte big-endian counter at nonce[4..11]
|
||||||
|
val bb = ByteBuffer.wrap(nonce, NONCE_SALT_LEN, 8)
|
||||||
|
bb.putLong(remoteCounterValue)
|
||||||
|
|
||||||
|
val plaintext = try {
|
||||||
|
aeadDecrypt(nonce, ciphertext, tag)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
close()
|
||||||
|
throw SecurityException("AEAD authentication failed; connection closed.", ex)
|
||||||
|
} finally {
|
||||||
|
nonce.fill(0)
|
||||||
|
payload.fill(0)
|
||||||
|
ciphertext.fill(0)
|
||||||
|
tag.fill(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plaintext
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun write(buffer: ByteArray, offset: Int, count: Int) = withContext(Dispatchers.IO) {
|
||||||
|
if (closed) throw IllegalStateException("AbyssStream closed")
|
||||||
|
if (buffer.size < offset + count) throw IndexOutOfBoundsException()
|
||||||
|
var remaining = count
|
||||||
|
var idx = offset
|
||||||
|
while (remaining > 0) {
|
||||||
|
val chunk = kotlin.math.min(remaining, MAX_PLAINTEXT_FRAME)
|
||||||
|
val plaintext = buffer.copyOfRange(idx, idx + chunk)
|
||||||
|
sendPlaintextChunk(plaintext)
|
||||||
|
idx += chunk
|
||||||
|
remaining -= chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendPlaintextChunk(plaintext: ByteArray) {
|
||||||
|
if (closed) throw IllegalStateException("AbyssStream closed")
|
||||||
|
|
||||||
|
val nonce = ByteArray(NONCE_LEN)
|
||||||
|
val ciphertextAndTag: ByteArray
|
||||||
|
val counterValue: Long
|
||||||
|
synchronized(sendLock) {
|
||||||
|
counterValue = sendCounter.getAndIncrement()
|
||||||
|
}
|
||||||
|
|
||||||
|
System.arraycopy(sendSalt, 0, nonce, 0, NONCE_SALT_LEN)
|
||||||
|
ByteBuffer.wrap(nonce, NONCE_SALT_LEN, 8).putLong(counterValue)
|
||||||
|
|
||||||
|
try {
|
||||||
|
ciphertextAndTag = aeadEncrypt(nonce, plaintext)
|
||||||
|
} finally {
|
||||||
|
nonce.fill(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val payloadLen = ciphertextAndTag.size
|
||||||
|
// header + ciphertextAndTag 一次性合并
|
||||||
|
val packet = ByteBuffer.allocate(4 + payloadLen)
|
||||||
|
.putInt(payloadLen)
|
||||||
|
.put(ciphertextAndTag)
|
||||||
|
.array()
|
||||||
|
|
||||||
|
try {
|
||||||
|
synchronized(output) {
|
||||||
|
output.write(packet)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// clear sensitive
|
||||||
|
ciphertextAndTag.fill(0)
|
||||||
|
plaintext.fill(0)
|
||||||
|
packet.fill(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AEAD helpers using BouncyCastle lightweight API ----
|
||||||
|
// ChaCha20-Poly1305 with 12-byte nonce. BouncyCastle ChaCha20Poly1305 produces ciphertext+tag.
|
||||||
|
|
||||||
|
private fun aeadEncrypt(nonce: ByteArray, plaintext: ByteArray): ByteArray {
|
||||||
|
synchronized(aeadLock) {
|
||||||
|
val cipher = ChaCha20Poly1305()
|
||||||
|
val params = AEADParameters(KeyParameter(aeadKey), AEAD_TAG_LEN * 8, nonce, null)
|
||||||
|
cipher.init(true, params)
|
||||||
|
val outBuf = ByteArray(cipher.getOutputSize(plaintext.size))
|
||||||
|
var len = cipher.processBytes(plaintext, 0, plaintext.size, outBuf, 0)
|
||||||
|
len += cipher.doFinal(outBuf, len)
|
||||||
|
if (len != outBuf.size) return outBuf.copyOf(len)
|
||||||
|
return outBuf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun aeadDecrypt(nonce: ByteArray, ciphertext: ByteArray, tag: ByteArray): ByteArray {
|
||||||
|
synchronized(aeadLock) {
|
||||||
|
val cipher = ChaCha20Poly1305()
|
||||||
|
val params = AEADParameters(KeyParameter(aeadKey), AEAD_TAG_LEN * 8, nonce, null)
|
||||||
|
cipher.init(false, params)
|
||||||
|
// input is ciphertext||tag
|
||||||
|
val input = ByteArray(ciphertext.size + tag.size)
|
||||||
|
if (ciphertext.isNotEmpty()) System.arraycopy(ciphertext, 0, input, 0, ciphertext.size)
|
||||||
|
System.arraycopy(tag, 0, input, ciphertext.size, tag.size)
|
||||||
|
val outBuf = ByteArray(cipher.getOutputSize(input.size))
|
||||||
|
var len = cipher.processBytes(input, 0, input.size, outBuf, 0)
|
||||||
|
try {
|
||||||
|
len += cipher.doFinal(outBuf, len)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
// authentication failure or other
|
||||||
|
throw ex
|
||||||
|
}
|
||||||
|
return if (len != outBuf.size) outBuf.copyOf(len) else outBuf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- utility / lifecycle ----
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
if (!closed) {
|
||||||
|
closed = true
|
||||||
|
try { socket.close() } catch (_: Exception) {}
|
||||||
|
// clear secrets
|
||||||
|
aeadKey.fill(0)
|
||||||
|
sendSalt.fill(0)
|
||||||
|
recvSalt.fill(0)
|
||||||
|
synchronized(leftoverQueue) {
|
||||||
|
leftoverQueue.forEach { it.fill(0) }
|
||||||
|
leftoverQueue.clear()
|
||||||
|
}
|
||||||
|
currentLeftover = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.acitelight.aether.service
|
||||||
|
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.acitelight.aether.service.AuthManager.db64
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.net.Socket
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class AbyssTunnelProxy @Inject constructor(
|
||||||
|
private val settingsDataStoreManager: SettingsDataStoreManager
|
||||||
|
) {
|
||||||
|
private val coroutineContext: CoroutineContext = Dispatchers.IO
|
||||||
|
private var serverHost: String = ""
|
||||||
|
private var serverPort: Int = 0
|
||||||
|
|
||||||
|
fun config(host: String, port: Int)
|
||||||
|
{
|
||||||
|
serverHost = host
|
||||||
|
serverPort = port
|
||||||
|
}
|
||||||
|
|
||||||
|
private val listenAddress = InetAddress.getLoopbackAddress()
|
||||||
|
private val listenPort = 4095
|
||||||
|
private var serverSocket: ServerSocket? = null
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + coroutineContext)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
serverSocket = ServerSocket(listenPort, 50, listenAddress)
|
||||||
|
// accept loop
|
||||||
|
scope.launch {
|
||||||
|
val srv = serverSocket ?: return@launch
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
val client = srv.accept()
|
||||||
|
|
||||||
|
if(serverHost.isEmpty())
|
||||||
|
continue
|
||||||
|
|
||||||
|
launch {
|
||||||
|
try { handleLocalConnection(client) }
|
||||||
|
catch (ex: Exception) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
println(ex.message)
|
||||||
|
// server stopped or fatal error
|
||||||
|
} finally {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
try { serverSocket?.close() } catch (_: Exception) {}
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleLocalConnection(localSocket: Socket) = withContext(coroutineContext) {
|
||||||
|
val localIn = localSocket.getInputStream()
|
||||||
|
val localOut = localSocket.getOutputStream()
|
||||||
|
var abyssSocket: Socket? = null
|
||||||
|
var abyssStream: AbyssStream? = null
|
||||||
|
try {
|
||||||
|
abyssSocket = Socket(serverHost, serverPort)
|
||||||
|
abyssStream = AbyssStream.create(abyssSocket, db64(settingsDataStoreManager.privateKeyFlow.first()))
|
||||||
|
|
||||||
|
// concurrently copy in both directions
|
||||||
|
val job1 = launch { copyExactSuspend(localIn, abyssStream) } // local -> abyss
|
||||||
|
val job2 = launch { copyFromAbyssToLocal(abyssStream, localOut) } // abyss -> local
|
||||||
|
|
||||||
|
// wait for either direction to finish
|
||||||
|
select<Unit> {
|
||||||
|
job1.onJoin { /* completed */ }
|
||||||
|
job2.onJoin { /* completed */ }
|
||||||
|
}
|
||||||
|
// cancel other
|
||||||
|
job1.cancel()
|
||||||
|
job2.cancel()
|
||||||
|
} catch (ex: Exception)
|
||||||
|
{
|
||||||
|
println(ex.message)
|
||||||
|
// log or ignore; we close sockets below
|
||||||
|
} finally {
|
||||||
|
try { localSocket.close() } catch (_: Exception) {}
|
||||||
|
try { abyssStream?.close() } catch (_: Exception) {}
|
||||||
|
try { abyssSocket?.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy from local InputStream into AbyssStream.write in frames.
|
||||||
|
private suspend fun copyExactSuspend(localIn: InputStream, abyss: AbyssStream) = withContext(coroutineContext) {
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val read = localIn.read(buffer)
|
||||||
|
if (read <= 0)
|
||||||
|
break
|
||||||
|
|
||||||
|
abyss.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy from AbyssStream (read frames/decrypt) to local OutputStream
|
||||||
|
private suspend fun copyFromAbyssToLocal(abyss: AbyssStream, localOut: OutputStream) = withContext(coroutineContext) {
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val n = abyss.read(buffer, 0, buffer.size)
|
||||||
|
if (n <= 0)
|
||||||
|
break
|
||||||
|
localOut.write(buffer, 0, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,32 +2,56 @@
|
|||||||
package com.acitelight.aether.service
|
package com.acitelight.aether.service
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.acitelight.aether.AetherApp
|
||||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.CertificatePinner
|
import okhttp3.ConnectionSpec
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.EventListener
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
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.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Proxy
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import java.security.cert.Certificate
|
import java.security.cert.CertificateException
|
||||||
import java.security.cert.CertificateFactory
|
import java.security.cert.CertificateFactory
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
import javax.net.ssl.TrustManagerFactory
|
import javax.net.ssl.TrustManagerFactory
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
|
||||||
object ApiClient {
|
object ApiClient {
|
||||||
var base: String = ""
|
fun getBase(): String{
|
||||||
|
return replaceAbyssProtocol(base)
|
||||||
|
}
|
||||||
|
private var base: String = ""
|
||||||
var domain: String = ""
|
var domain: String = ""
|
||||||
var cert: String = ""
|
var cert: String = ""
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun replaceAbyssProtocol(uri: String): String {
|
||||||
|
return uri.replaceFirst("^abyss://".toRegex(), "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val dnsEventListener = object : EventListener() {
|
||||||
|
override fun dnsEnd(call: okhttp3.Call, domainName: String, inetAddressList: List<InetAddress>) {
|
||||||
|
super.dnsEnd(call, domainName, inetAddressList)
|
||||||
|
val ipAddresses = inetAddressList.joinToString(", ") { it.hostAddress ?: "" }
|
||||||
|
Log.d("OkHttp_DNS", "Domain '$domainName' resolved to IPs: [$ipAddresses]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -42,43 +66,128 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate): OkHttpClient {
|
fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate?): OkHttpClient {
|
||||||
try {
|
try {
|
||||||
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
val defaultTmFactory = TrustManagerFactory.getInstance(
|
||||||
load(null, null)
|
TrustManagerFactory.getDefaultAlgorithm()
|
||||||
setCertificateEntry("ca", trustedCert)
|
).apply {
|
||||||
|
init(null as KeyStore?)
|
||||||
|
}
|
||||||
|
val defaultTm = defaultTmFactory.trustManagers
|
||||||
|
.first { it is X509TrustManager } as X509TrustManager
|
||||||
|
|
||||||
|
val customTm: X509TrustManager? = trustedCert?.let {
|
||||||
|
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
|
||||||
|
load(null, null)
|
||||||
|
setCertificateEntry("ca", it)
|
||||||
|
}
|
||||||
|
val tmf = TrustManagerFactory.getInstance(
|
||||||
|
TrustManagerFactory.getDefaultAlgorithm()
|
||||||
|
).apply {
|
||||||
|
init(keyStore)
|
||||||
|
}
|
||||||
|
tmf.trustManagers.first { it is X509TrustManager } as X509TrustManager
|
||||||
}
|
}
|
||||||
|
|
||||||
val tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm()
|
val combinedTm = object : X509TrustManager {
|
||||||
val tmf = TrustManagerFactory.getInstance(tmfAlgorithm).apply {
|
override fun getAcceptedIssuers(): Array<X509Certificate> {
|
||||||
init(keyStore)
|
return (defaultTm.acceptedIssuers + (customTm?.acceptedIssuers ?: emptyArray()))
|
||||||
}
|
}
|
||||||
|
|
||||||
val trustManager = tmf.trustManagers.first { it is X509TrustManager } as X509TrustManager
|
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
var passed = false
|
||||||
|
try {
|
||||||
|
defaultTm.checkClientTrusted(chain, authType)
|
||||||
|
passed = true
|
||||||
|
} catch (_: CertificateException) { }
|
||||||
|
if (!passed && customTm != null) {
|
||||||
|
customTm.checkClientTrusted(chain, authType)
|
||||||
|
passed = true
|
||||||
|
}
|
||||||
|
if (!passed) throw CertificateException("Untrusted client certificate chain")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
|
||||||
|
var passed = false
|
||||||
|
try {
|
||||||
|
defaultTm.checkServerTrusted(chain, authType)
|
||||||
|
passed = true
|
||||||
|
} catch (_: CertificateException) { }
|
||||||
|
if (!passed && customTm != null) {
|
||||||
|
customTm.checkServerTrusted(chain, authType)
|
||||||
|
passed = true
|
||||||
|
}
|
||||||
|
if (!passed) throw CertificateException("Untrusted server certificate chain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val sslContext = SSLContext.getInstance("TLS").apply {
|
val sslContext = SSLContext.getInstance("TLS").apply {
|
||||||
init(null, arrayOf(trustManager), null)
|
init(null, arrayOf(combinedTm), null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return OkHttpClient.Builder()
|
return if (base.startsWith("abyss://"))
|
||||||
.sslSocketFactory(sslContext.socketFactory, trustManager)
|
OkHttpClient.Builder()
|
||||||
.build()
|
.connectionSpecs(
|
||||||
|
listOf(
|
||||||
|
ConnectionSpec.MODERN_TLS,
|
||||||
|
ConnectionSpec.COMPATIBLE_TLS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.proxy(
|
||||||
|
Proxy(
|
||||||
|
Proxy.Type.HTTP,
|
||||||
|
InetSocketAddress("::1", 4095)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.sslSocketFactory(sslContext.socketFactory, combinedTm)
|
||||||
|
.build()
|
||||||
|
else
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectionSpecs(
|
||||||
|
listOf(
|
||||||
|
ConnectionSpec.MODERN_TLS,
|
||||||
|
ConnectionSpec.COMPATIBLE_TLS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.sslSocketFactory(sslContext.socketFactory, combinedTm)
|
||||||
|
.build()
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw RuntimeException("Failed to create OkHttpClient with dynamic certificate", e)
|
throw RuntimeException("Failed to create OkHttpClient with dynamic certificate", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createOkHttp(): OkHttpClient
|
fun createOkHttp(): OkHttpClient {
|
||||||
{
|
return if (cert == "")
|
||||||
return createOkHttpClientWithDynamicCert(loadCertificateFromString(cert))
|
if (base.startsWith("abyss://"))
|
||||||
|
OkHttpClient
|
||||||
|
.Builder()
|
||||||
|
.proxy(
|
||||||
|
Proxy(
|
||||||
|
Proxy.Type.HTTP,
|
||||||
|
InetSocketAddress("::1", 4095)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS))
|
||||||
|
.eventListener(dnsEventListener)
|
||||||
|
.build()
|
||||||
|
else
|
||||||
|
OkHttpClient
|
||||||
|
.Builder()
|
||||||
|
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS))
|
||||||
|
.eventListener(dnsEventListener)
|
||||||
|
.build()
|
||||||
|
else
|
||||||
|
createOkHttpClientWithDynamicCert(loadCertificateFromString(cert))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createRetrofit(): Retrofit {
|
private fun createRetrofit(): Retrofit {
|
||||||
val okHttpClient = createOkHttpClientWithDynamicCert(loadCertificateFromString(cert))
|
val okHttpClient = createOkHttp()
|
||||||
|
val b = replaceAbyssProtocol(base)
|
||||||
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(base)
|
.baseUrl(b)
|
||||||
.client(okHttpClient)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
@@ -88,19 +197,52 @@ object ApiClient {
|
|||||||
|
|
||||||
var api: ApiInterface? = null
|
var api: ApiInterface? = null
|
||||||
|
|
||||||
fun apply(url: String, crt: String)
|
suspend fun apply(context: Context, urls: String, crt: String): String? {
|
||||||
{
|
try {
|
||||||
try{
|
val urlList = urls.split(";").map { it.trim() }
|
||||||
domain = url.toHttpUrlOrNull()?.host !!
|
|
||||||
|
var selectedUrl: String? = null
|
||||||
|
for (url in urlList) {
|
||||||
|
val host = url.toUri().host
|
||||||
|
if (host != null && pingHost(host)) {
|
||||||
|
selectedUrl = url
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedUrl == null) {
|
||||||
|
throw Exception("No reachable URL found")
|
||||||
|
}
|
||||||
|
|
||||||
|
domain = selectedUrl.toHttpUrlOrNull()?.host ?: ""
|
||||||
cert = crt
|
cert = crt
|
||||||
base = url
|
base = selectedUrl
|
||||||
|
withContext(Dispatchers.IO)
|
||||||
|
{
|
||||||
|
(context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
|
||||||
|
}
|
||||||
api = createRetrofit().create(ApiInterface::class.java)
|
api = createRetrofit().create(ApiInterface::class.java)
|
||||||
}catch (e: Exception)
|
|
||||||
{
|
Log.i("Delay Analyze", "Start Abyss Hello")
|
||||||
|
val h = api!!.hello()
|
||||||
|
Log.i("Delay Analyze", "Abyss Hello: ${h.string()}")
|
||||||
|
|
||||||
|
return base
|
||||||
|
} catch (e: Exception) {
|
||||||
api = null
|
api = null
|
||||||
base = ""
|
base = ""
|
||||||
domain = ""
|
domain = ""
|
||||||
cert = ""
|
cert = ""
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pingHost(host: String): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
return@withContext try {
|
||||||
|
val address = InetAddress.getByName(host)
|
||||||
|
address.isReachable(200)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.acitelight.aether.service
|
package com.acitelight.aether.service
|
||||||
|
|
||||||
|
import com.acitelight.aether.model.BookMark
|
||||||
import com.acitelight.aether.model.ChallengeResponse
|
import com.acitelight.aether.model.ChallengeResponse
|
||||||
import com.acitelight.aether.model.Comic
|
import com.acitelight.aether.model.ComicResponse
|
||||||
import com.acitelight.aether.model.VideoResponse
|
import com.acitelight.aether.model.VideoResponse
|
||||||
|
import okhttp3.Response
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
@@ -28,19 +30,23 @@ interface ApiInterface {
|
|||||||
@Query("token") token: String
|
@Query("token") token: String
|
||||||
): VideoResponse
|
): VideoResponse
|
||||||
|
|
||||||
@GET("api/video/{klass}/{id}/nv")
|
@POST("api/video/{klass}/bulkquery")
|
||||||
@Streaming
|
suspend fun queryVideoBulk(
|
||||||
suspend fun getNailVideo(
|
|
||||||
@Path("klass") klass: String,
|
@Path("klass") klass: String,
|
||||||
@Path("id") id: String,
|
@Body() id: List<String>,
|
||||||
@Query("token") token: String
|
@Query("token") token: String
|
||||||
): ResponseBody
|
): List<VideoResponse>
|
||||||
|
|
||||||
@GET("api/image/collections")
|
@GET("api/image")
|
||||||
suspend fun getComicCollections(): List<String>
|
suspend fun getComics(@Query("token") token: String): List<String>
|
||||||
@GET("api/image/meta")
|
@GET("api/image/{id}")
|
||||||
suspend fun queryComicInfo(@Query("collection") collection: String): Comic
|
suspend fun queryComicInfo(@Path("id") id: String, @Query("token") token: String): ComicResponse
|
||||||
|
|
||||||
|
@POST("api/image/bulkquery")
|
||||||
|
suspend fun queryComicInfoBulk(@Body() id: List<String>, @Query("token") token: String): List<ComicResponse>
|
||||||
|
|
||||||
|
@POST("api/image/{id}/bookmark")
|
||||||
|
suspend fun postBookmark(@Path("id") id: String, @Query("token") token: String, @Body bookmark: BookMark)
|
||||||
|
|
||||||
@GET("api/user/{user}")
|
@GET("api/user/{user}")
|
||||||
suspend fun getChallenge(
|
suspend fun getChallenge(
|
||||||
@@ -52,4 +58,7 @@ interface ApiInterface {
|
|||||||
@Path("user") user: String,
|
@Path("user") user: String,
|
||||||
@Body challengeResponse: ChallengeResponse
|
@Body challengeResponse: ChallengeResponse
|
||||||
): ResponseBody
|
): ResponseBody
|
||||||
|
|
||||||
|
@GET("api/abyss")
|
||||||
|
suspend fun hello(): ResponseBody
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
package com.acitelight.aether.service
|
package com.acitelight.aether.service
|
||||||
|
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
import com.acitelight.aether.model.ChallengeResponse
|
import com.acitelight.aether.model.ChallengeResponse
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.EventListener
|
||||||
|
import okhttp3.Handshake
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||||
import org.bouncycastle.crypto.signers.Ed25519Signer
|
import org.bouncycastle.crypto.signers.Ed25519Signer
|
||||||
|
import java.io.IOException
|
||||||
|
import java.lang.reflect.Proxy
|
||||||
|
import java.net.InetSocketAddress
|
||||||
import java.security.PrivateKey
|
import java.security.PrivateKey
|
||||||
import java.security.Signature
|
import java.security.Signature
|
||||||
|
|
||||||
@@ -17,13 +28,13 @@ object AuthManager {
|
|||||||
challengeBase64 = api!!.getChallenge(username).string()
|
challengeBase64 = api!!.getChallenge(username).string()
|
||||||
}catch (e: Exception)
|
}catch (e: Exception)
|
||||||
{
|
{
|
||||||
print(e.message)
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64))
|
val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64))
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
api!!.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string()
|
api.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
null
|
null
|
||||||
@@ -44,4 +55,14 @@ object AuthManager {
|
|||||||
val signature = signer.generateSignature()
|
val signature = signer.generateSignature()
|
||||||
return Base64.encodeToString(signature, Base64.NO_WRAP)
|
return Base64.encodeToString(signature, Base64.NO_WRAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun signChallengeByte(privateKey: ByteArray, data: ByteArray): ByteArray //64 Byte
|
||||||
|
{
|
||||||
|
val privateKeyParams = Ed25519PrivateKeyParameters(privateKey, 0)
|
||||||
|
val signer = Ed25519Signer()
|
||||||
|
signer.init(true, privateKeyParams)
|
||||||
|
|
||||||
|
signer.update(data, 0, data.size)
|
||||||
|
return signer.generateSignature()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
191
app/src/main/java/com/acitelight/aether/service/FetchManager.kt
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package com.acitelight.aether.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import com.acitelight.aether.Screen
|
||||||
|
import com.acitelight.aether.model.Video
|
||||||
|
import com.acitelight.aether.service.ApiClient.createOkHttp
|
||||||
|
import com.tonyodev.fetch2.Download
|
||||||
|
import com.tonyodev.fetch2.Fetch
|
||||||
|
import com.tonyodev.fetch2.FetchConfiguration
|
||||||
|
import com.tonyodev.fetch2.FetchListener
|
||||||
|
import com.tonyodev.fetch2.Request
|
||||||
|
import com.tonyodev.fetch2.Status
|
||||||
|
import com.tonyodev.fetch2core.Extras
|
||||||
|
import com.tonyodev.fetch2okhttp.OkHttpDownloader
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class FetchManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private var fetch: Fetch? = null
|
||||||
|
private var listener: FetchListener? = null
|
||||||
|
private var client: OkHttpClient? = null
|
||||||
|
val configured = MutableStateFlow(false)
|
||||||
|
|
||||||
|
fun init() {
|
||||||
|
client = createOkHttp()
|
||||||
|
val fetchConfiguration = FetchConfiguration.Builder(context)
|
||||||
|
.setDownloadConcurrentLimit(8)
|
||||||
|
.setHttpDownloader(OkHttpDownloader(client))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fetch = Fetch.Impl.getInstance(fetchConfiguration)
|
||||||
|
configured.update { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// listener management
|
||||||
|
suspend fun setListener(l: FetchListener) {
|
||||||
|
configured.filter { it }.first()
|
||||||
|
|
||||||
|
listener?.let { fetch?.removeListener(it) }
|
||||||
|
listener = l
|
||||||
|
fetch?.addListener(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeListener() {
|
||||||
|
listener?.let {
|
||||||
|
fetch?.removeListener(it)
|
||||||
|
}
|
||||||
|
listener = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// query downloads
|
||||||
|
suspend fun getAllDownloads(callback: (List<Download>) -> Unit) {
|
||||||
|
configured.filter { it }.first()
|
||||||
|
fetch?.getDownloads { list -> callback(list) } ?: callback(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getAllDownloadsAsync(): List<Download> {
|
||||||
|
configured.filter { it }.first()
|
||||||
|
val completed = MutableStateFlow(false)
|
||||||
|
var r = listOf<Download>()
|
||||||
|
|
||||||
|
fetch?.getDownloads { list ->
|
||||||
|
r = list
|
||||||
|
completed.update { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
completed.filter { it }.first()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// operations
|
||||||
|
fun pause(id: Int) {
|
||||||
|
fetch?.pause(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resume(id: Int) {
|
||||||
|
fetch?.resume(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(id: Int) {
|
||||||
|
fetch?.cancel(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(id: Int, callback: (() -> Unit)? = null) {
|
||||||
|
fetch?.delete(id) {
|
||||||
|
callback?.invoke()
|
||||||
|
} ?: callback?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun enqueue(
|
||||||
|
request: Request,
|
||||||
|
onEnqueued: ((Request) -> Unit)? = null,
|
||||||
|
onError: ((com.tonyodev.fetch2.Error) -> Unit)? = null
|
||||||
|
) {
|
||||||
|
configured.filter { it }.first()
|
||||||
|
fetch?.enqueue(request, { r -> onEnqueued?.invoke(r) }, { e -> onError?.invoke(e) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeFolder(video: Video) {
|
||||||
|
val appFilesDir = context.getExternalFilesDir(null)
|
||||||
|
val videosDir = File(appFilesDir, "videos/${video.klass}/${video.id}/gallery")
|
||||||
|
videosDir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun startVideoDownload(video: Video) {
|
||||||
|
makeFolder(video)
|
||||||
|
File(
|
||||||
|
context.getExternalFilesDir(null),
|
||||||
|
"videos/${video.klass}/${video.id}/summary.json"
|
||||||
|
).writeText(Json.encodeToString(video))
|
||||||
|
|
||||||
|
val videoPath =
|
||||||
|
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/video.mp4")
|
||||||
|
val coverPath =
|
||||||
|
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/cover.jpg")
|
||||||
|
val subtitlePath = File(
|
||||||
|
context.getExternalFilesDir(null),
|
||||||
|
"videos/${video.klass}/${video.id}/subtitle.vtt"
|
||||||
|
)
|
||||||
|
|
||||||
|
val requests = mutableListOf(
|
||||||
|
Request(video.getVideo(), 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(), 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(), 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()) {
|
||||||
|
requests.add(
|
||||||
|
Request(p.url, File(
|
||||||
|
context.getExternalFilesDir(null),
|
||||||
|
"videos/${video.klass}/${video.id}/gallery/${p.name}"
|
||||||
|
).path).apply {
|
||||||
|
extras = Extras(
|
||||||
|
mapOf(
|
||||||
|
"name" to video.video.name,
|
||||||
|
"id" to video.id,
|
||||||
|
"class" to video.klass,
|
||||||
|
"type" to "gallery"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in requests)
|
||||||
|
enqueue(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
package com.acitelight.aether.service
|
package com.acitelight.aether.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.acitelight.aether.model.BookMark
|
||||||
import com.acitelight.aether.model.Comic
|
import com.acitelight.aether.model.Comic
|
||||||
import com.acitelight.aether.model.Video
|
import com.acitelight.aether.model.Video
|
||||||
import kotlinx.coroutines.Dispatchers
|
import com.tonyodev.fetch2.Status
|
||||||
import kotlinx.coroutines.withContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.io.IOException
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
|
||||||
object MediaManager
|
@Singleton
|
||||||
|
class MediaManager @Inject constructor(
|
||||||
|
val fetchManager: FetchManager,
|
||||||
|
@ApplicationContext val context: Context
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var token: String = "null"
|
var token: String = "null"
|
||||||
|
|
||||||
@@ -17,46 +26,150 @@ object MediaManager
|
|||||||
{
|
{
|
||||||
val j = ApiClient.api!!.getVideoClasses(token)
|
val j = ApiClient.api!!.getVideoClasses(token)
|
||||||
return j.toList()
|
return j.toList()
|
||||||
}catch(e: Exception)
|
}catch(_: Exception)
|
||||||
{
|
{
|
||||||
return listOf()
|
return listOf()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun listVideos(klass: String, callback: (Video) -> Unit)
|
suspend fun queryVideoKlasses(klass: String): List<String>
|
||||||
{
|
{
|
||||||
val j = ApiClient.api!!.queryVideoClasses(klass, token)
|
try
|
||||||
for(it in j)
|
|
||||||
{
|
{
|
||||||
try {
|
val j = ApiClient.api!!.queryVideoClasses(klass, token)
|
||||||
callback(queryVideo(klass, it)!!)
|
return j.toList()
|
||||||
}catch (e: Exception)
|
}catch(_: Exception)
|
||||||
{
|
{
|
||||||
|
return listOf()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun queryVideo(klass: String, id: String): Video?
|
suspend fun queryVideo(klass: String, id: String): Video?
|
||||||
{
|
{
|
||||||
|
val downloaded = fetchManager.getAllDownloadsAsync().filter {
|
||||||
|
it.status == Status.COMPLETED &&
|
||||||
|
it.extras.getString("id", "") == id &&
|
||||||
|
it.extras.getString("class", "") == klass
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!downloaded.isEmpty())
|
||||||
|
{
|
||||||
|
val jsonString = File(
|
||||||
|
context.getExternalFilesDir(null),
|
||||||
|
"videos/$klass/$id/summary.json"
|
||||||
|
).readText()
|
||||||
|
return Json.decodeFromString<Video>(jsonString).toLocal(context.getExternalFilesDir(null)?.path!!)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val j = ApiClient.api!!.queryVideo(klass, id, token)
|
val j = ApiClient.api!!.queryVideo(klass, id, token)
|
||||||
return Video(klass = klass, id = id, token=token, j)
|
return Video(klass = klass, id = id, token=token, isLocal = false, localBase = "", video = j)
|
||||||
}catch (e: Exception)
|
}catch (_: Exception)
|
||||||
{
|
{
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun listComics() : List<String>
|
suspend fun queryVideoBulk(klass: String, id: List<String>): List<Video>? {
|
||||||
{
|
return try {
|
||||||
// TODO: try
|
val completedDownloads = fetchManager.getAllDownloadsAsync()
|
||||||
return ApiClient.api!!.getComicCollections()
|
.filter { it.status == Status.COMPLETED }
|
||||||
|
val localIds = mutableSetOf<String>()
|
||||||
|
val remoteIds = mutableListOf<String>()
|
||||||
|
|
||||||
|
for (videoId in id) {
|
||||||
|
if (completedDownloads.any {
|
||||||
|
it.extras.getString("id", "") == videoId &&
|
||||||
|
it.extras.getString("class", "") == klass
|
||||||
|
}) {
|
||||||
|
localIds.add(videoId)
|
||||||
|
} else {
|
||||||
|
remoteIds.add(videoId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val localVideos = localIds.mapNotNull { videoId ->
|
||||||
|
val localFile = File(
|
||||||
|
context.getExternalFilesDir(null),
|
||||||
|
"videos/$klass/$videoId/summary.json"
|
||||||
|
)
|
||||||
|
if (localFile.exists()) {
|
||||||
|
try {
|
||||||
|
val jsonString = localFile.readText()
|
||||||
|
Json.decodeFromString<Video>(jsonString).toLocal(
|
||||||
|
context.getExternalFilesDir(null)?.path ?: ""
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val remoteVideos = if (remoteIds.isNotEmpty()) {
|
||||||
|
val j = ApiClient.api!!.queryVideoBulk(klass, remoteIds, token)
|
||||||
|
j.zip(remoteIds).map {
|
||||||
|
Video(
|
||||||
|
klass = klass,
|
||||||
|
id = it.second,
|
||||||
|
token = token,
|
||||||
|
isLocal = false,
|
||||||
|
localBase = "",
|
||||||
|
video = it.first
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
localVideos + remoteVideos
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun queryComicInfo(c: String) : Comic
|
suspend fun listComics() : List<String>
|
||||||
{
|
{
|
||||||
// TODO: try
|
try{
|
||||||
return ApiClient.api!!.queryComicInfo(c)
|
val j = ApiClient.api!!.getComics(token)
|
||||||
|
return j
|
||||||
|
}catch (_: Exception)
|
||||||
|
{
|
||||||
|
return listOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun queryComicInfoSingle(id: String) : Comic?
|
||||||
|
{
|
||||||
|
try{
|
||||||
|
val j = ApiClient.api!!.queryComicInfo(id, token)
|
||||||
|
return Comic(id = id, comic = j, token = token)
|
||||||
|
}catch (_: Exception)
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun queryComicInfoBulk(id: List<String>) : List<Comic>?
|
||||||
|
{
|
||||||
|
try{
|
||||||
|
val j = ApiClient.api!!.queryComicInfoBulk(id, token)
|
||||||
|
return j.zip(id).map { Comic(id = it.second, comic = it.first, token = token) }
|
||||||
|
}catch (_: Exception)
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun postBookmark(id: String, bookMark: BookMark): Boolean
|
||||||
|
{
|
||||||
|
try{
|
||||||
|
ApiClient.api!!.postBookmark(id, token, bookMark)
|
||||||
|
return true
|
||||||
|
}catch (_: Exception)
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,11 +2,10 @@ package com.acitelight.aether.service
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import com.acitelight.aether.model.Comic
|
||||||
import com.acitelight.aether.model.Video
|
import com.acitelight.aether.model.Video
|
||||||
import com.acitelight.aether.model.VideoQueryIndex
|
import com.acitelight.aether.model.VideoQueryIndex
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -14,26 +13,31 @@ import kotlinx.serialization.json.*
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
object RecentManager
|
@Singleton
|
||||||
|
class RecentManager @Inject constructor(
|
||||||
|
private val mediaManager: MediaManager
|
||||||
|
)
|
||||||
{
|
{
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
suspend fun readFile(context: Context, filename: String): String {
|
private suspend fun readFile(context: Context, filename: String): String {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val file = File(context.filesDir, filename)
|
val file = File(context.filesDir, filename)
|
||||||
val content = file.readText()
|
val content = file.readText()
|
||||||
content
|
content
|
||||||
} catch (e: FileNotFoundException) {
|
} catch (_: FileNotFoundException) {
|
||||||
"[]"
|
"[]"
|
||||||
} catch (e: IOException) {
|
} catch (_: IOException) {
|
||||||
"[]"
|
"[]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun writeFile(context: Context, filename: String, content: String) {
|
private suspend fun writeFile(context: Context, filename: String, content: String) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val file = File(context.filesDir, filename)
|
val file = File(context.filesDir, filename)
|
||||||
@@ -45,19 +49,92 @@ object RecentManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun Query(context: Context): List<VideoQueryIndex>
|
suspend fun queryComic(context: Context): List<String> {
|
||||||
|
val content = readFile(context, "recent_comic.json")
|
||||||
|
try {
|
||||||
|
val ids = Json.decodeFromString<List<String>>(content)
|
||||||
|
|
||||||
|
|
||||||
|
recentComic.clear()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val comics = mediaManager.queryComicInfoBulk(ids)
|
||||||
|
if (comics != null) {
|
||||||
|
for (c in comics) {
|
||||||
|
recentComic.add(recentComic.size, c)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (id in ids) {
|
||||||
|
val c = mediaManager.queryComicInfoSingle(id)
|
||||||
|
if (c != null) recentComic.add(recentComic.size, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: NoSuchMethodError) {
|
||||||
|
for (id in ids) {
|
||||||
|
val c = mediaManager.queryComicInfoSingle(id)
|
||||||
|
if (c != null) recentComic.add(recentComic.size, c)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
for (id in ids) {
|
||||||
|
val c = mediaManager.queryComicInfoSingle(id)
|
||||||
|
if (c != null) recentComic.add(recentComic.size, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return ids
|
||||||
|
} catch (e: Exception) {
|
||||||
|
print(e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return listOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun pushComic(context: Context, comicId: String) {
|
||||||
|
mutex.withLock {
|
||||||
|
val o = recentComic.map { it.id }.toMutableList()
|
||||||
|
|
||||||
|
|
||||||
|
if (o.contains(comicId)) {
|
||||||
|
val index = o.indexOf(comicId)
|
||||||
|
recentComic.removeAt(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val comic = mediaManager.queryComicInfoSingle(comicId)
|
||||||
|
if (comic != null) {
|
||||||
|
recentComic.add(0, comic)
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentComic.size > 21) {
|
||||||
|
recentComic.removeAt(recentComic.size - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
writeFile(context, "recent_comic.json", Json.encodeToString(recentComic.map { it.id }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun queryVideo(context: Context): List<VideoQueryIndex>
|
||||||
{
|
{
|
||||||
val content = readFile(context, "recent.json")
|
val content = readFile(context, "recent.json")
|
||||||
try{
|
try{
|
||||||
val r = Json.decodeFromString<List<VideoQueryIndex>>(content)
|
val r = Json.decodeFromString<List<VideoQueryIndex>>(content)
|
||||||
|
|
||||||
recent.clear()
|
recentVideo.clear()
|
||||||
|
val gr = r.groupBy { it.klass }
|
||||||
|
|
||||||
for(it in r)
|
for(it in gr)
|
||||||
{
|
{
|
||||||
val v = MediaManager.queryVideo(it.klass, it.id)
|
val v = mediaManager.queryVideoBulk(it.key, it.value.map { it.id })
|
||||||
if(v != null)
|
if(v != null)
|
||||||
recent.add(recent.size, v)
|
for(j in v)
|
||||||
|
{
|
||||||
|
recentVideo.add(recentVideo.size, j)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
@@ -69,32 +146,26 @@ object RecentManager
|
|||||||
return listOf()
|
return listOf()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun Push(context: Context, video: VideoQueryIndex)
|
suspend fun pushVideo(context: Context, video: VideoQueryIndex)
|
||||||
{
|
{
|
||||||
mutex.withLock{
|
mutex.withLock{
|
||||||
val content = readFile(context, "recent.json")
|
val o = recentVideo.map{ VideoQueryIndex(it.klass, it.id) }.toMutableList()
|
||||||
var o = Json.decodeFromString<List<VideoQueryIndex>>(content).toMutableList();
|
|
||||||
|
|
||||||
if(o.contains(video))
|
if(o.contains(video))
|
||||||
{
|
{
|
||||||
val temp = o[0]
|
|
||||||
val index = o.indexOf(video)
|
val index = o.indexOf(video)
|
||||||
recent.removeAt(index)
|
recentVideo.removeAt(index)
|
||||||
o[0] = o[index]
|
|
||||||
o[index] = temp
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
o.add(0, video)
|
|
||||||
}
|
}
|
||||||
|
recentVideo.add(0, mediaManager.queryVideo(video.klass, video.id)!!)
|
||||||
|
|
||||||
if(o.size >= 21)
|
|
||||||
o.removeAt(o.size - 1)
|
|
||||||
|
|
||||||
recent.add(0, MediaManager.queryVideo(video.klass, video.id)!!)
|
if(recentVideo.size >= 21)
|
||||||
writeFile(context, "recent.json", Json.encodeToString(o))
|
recentVideo.removeAt(o.size - 1)
|
||||||
|
|
||||||
|
writeFile(context, "recent.json", Json.encodeToString(recentVideo.map{ VideoQueryIndex(it.klass, it.id) }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val recent = mutableStateListOf<Video>()
|
val recentVideo = mutableStateListOf<Video>()
|
||||||
|
val recentComic = mutableStateListOf<Comic>()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.acitelight.aether.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class SettingsDataStoreManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val USER_NAME_KEY = stringPreferencesKey("user_name")
|
||||||
|
val PRIVATE_KEY = stringPreferencesKey("private_key")
|
||||||
|
val URL_KEY = stringPreferencesKey("url")
|
||||||
|
val CERT_KEY = stringPreferencesKey("cert")
|
||||||
|
val USE_SELF_SIGNED_KEY = booleanPreferencesKey("use_self_signed")
|
||||||
|
}
|
||||||
|
|
||||||
|
val userNameFlow: Flow<String> = context.dataStore.data.map { preferences ->
|
||||||
|
preferences[USER_NAME_KEY] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val privateKeyFlow: Flow<String> = context.dataStore.data.map { preferences ->
|
||||||
|
preferences[PRIVATE_KEY] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val urlFlow: Flow<String> = context.dataStore.data.map { preferences ->
|
||||||
|
preferences[URL_KEY] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val certFlow: Flow<String> = context.dataStore.data.map { preferences ->
|
||||||
|
preferences[CERT_KEY] ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
val useSelfSignedFlow: Flow<Boolean> = context.dataStore.data.map { preferences ->
|
||||||
|
preferences[USE_SELF_SIGNED_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveUserName(name: String) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[USER_NAME_KEY] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun savePrivateKey(key: String) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[PRIVATE_KEY] = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveUrl(url: String) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[URL_KEY] = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveCert(cert: String) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[CERT_KEY] = cert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveUseSelfSigned(useSelfSigned: Boolean) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[USE_SELF_SIGNED_KEY] = useSelfSigned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun clearAll() {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.acitelight.aether.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import com.acitelight.aether.model.Video
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class VideoLibrary @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
|
||||||
|
var classes = mutableStateListOf<String>()
|
||||||
|
val classesMap = mutableStateMapOf<String, SnapshotStateList<Video>>()
|
||||||
|
val updatingMap: MutableMap<Int, Boolean> = mutableMapOf()
|
||||||
|
}
|
||||||
@@ -3,35 +3,135 @@ package com.acitelight.aether.ui.theme
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
fun generateColorScheme(primaryColor: Color, isDarkMode: Boolean): ColorScheme {
|
||||||
primary = Purple80,
|
|
||||||
secondary = PurpleGrey80,
|
|
||||||
tertiary = Pink80
|
|
||||||
)
|
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
val background = if (isDarkMode) Color(0xFF121212) else Color(0xFFFFFFFF)
|
||||||
primary = Purple40,
|
val surface = if (isDarkMode) Color(0xFF1E1E1E) else Color(0xFFFFFFFF)
|
||||||
secondary = PurpleGrey40,
|
|
||||||
tertiary = Pink40
|
|
||||||
|
|
||||||
/* Other default colors to override
|
val surfaceContainer = if (isDarkMode) Color(0xFF232323) else Color(0xFFFDFDFD)
|
||||||
background = Color(0xFFFFFBFE),
|
val surfaceContainerLow = if (isDarkMode) Color(0xFF1A1A1A) else Color(0xFFF5F5F5)
|
||||||
surface = Color(0xFFFFFBFE),
|
val surfaceContainerHigh = if (isDarkMode) Color(0xFF2A2A2A) else Color(0xFFFAFAFA)
|
||||||
onPrimary = Color.White,
|
val surfaceContainerHighest = if (isDarkMode) Color(0xFF333333) else Color(0xFFFFFFFF)
|
||||||
onSecondary = Color.White,
|
val surfaceContainerLowest = if (isDarkMode) Color(0xFF0F0F0F) else Color(0xFFF0F0F0)
|
||||||
onTertiary = Color.White,
|
val surfaceBright = if (isDarkMode) Color(0xFF2C2C2C) else Color(0xFFFFFFFF)
|
||||||
onBackground = Color(0xFF1C1B1F),
|
val surfaceDim = if (isDarkMode) Color(0xFF141414) else Color(0xFFF8F8F8)
|
||||||
onSurface = Color(0xFF1C1B1F),
|
|
||||||
*/
|
fun tint(surface: Color, factor: Float) = lerp(surface, primaryColor, factor)
|
||||||
)
|
|
||||||
|
return if (isDarkMode) {
|
||||||
|
darkColorScheme(
|
||||||
|
primary = primaryColor,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = tint(primaryColor, 0.2f),
|
||||||
|
onPrimaryContainer = Color.White,
|
||||||
|
inversePrimary = tint(primaryColor, 0.6f),
|
||||||
|
|
||||||
|
secondary = tint(primaryColor, 0.4f),
|
||||||
|
onSecondary = Color.White,
|
||||||
|
secondaryContainer = tint(primaryColor, 0.2f),
|
||||||
|
onSecondaryContainer = Color.White,
|
||||||
|
|
||||||
|
tertiary = tint(primaryColor, 0.5f),
|
||||||
|
onTertiary = Color.White,
|
||||||
|
tertiaryContainer = tint(primaryColor, 0.2f),
|
||||||
|
onTertiaryContainer = Color.White,
|
||||||
|
|
||||||
|
background = background,
|
||||||
|
onBackground = Color.White,
|
||||||
|
|
||||||
|
surface = surface,
|
||||||
|
onSurface = Color.White,
|
||||||
|
|
||||||
|
surfaceVariant = tint(surface, 0.1f),
|
||||||
|
onSurfaceVariant = Color(0xFFE0E0E0),
|
||||||
|
|
||||||
|
surfaceTint = primaryColor,
|
||||||
|
|
||||||
|
inverseSurface = Color.White,
|
||||||
|
inverseOnSurface = Color(0xFF121212),
|
||||||
|
|
||||||
|
error = Color(0xFFCF6679),
|
||||||
|
onError = Color.Black,
|
||||||
|
errorContainer = Color(0xFFB00020),
|
||||||
|
onErrorContainer = Color.White,
|
||||||
|
|
||||||
|
outline = Color(0xFF757575),
|
||||||
|
outlineVariant = Color(0xFF494949),
|
||||||
|
scrim = Color.Black,
|
||||||
|
|
||||||
|
surfaceBright = tint(surfaceBright, 0.1f),
|
||||||
|
surfaceContainer = tint(surfaceContainer, 0.1f),
|
||||||
|
surfaceContainerHigh = tint(surfaceContainerHigh, 0.12f),
|
||||||
|
surfaceContainerHighest = tint(surfaceContainerHighest, 0.15f),
|
||||||
|
surfaceContainerLow = tint(surfaceContainerLow, 0.08f),
|
||||||
|
surfaceContainerLowest = tint(surfaceContainerLowest, 0.05f),
|
||||||
|
surfaceDim = tint(surfaceDim, 0.1f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
lightColorScheme(
|
||||||
|
primary = primaryColor,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = tint(primaryColor, 0.3f),
|
||||||
|
onPrimaryContainer = Color.Black,
|
||||||
|
inversePrimary = tint(primaryColor, 0.6f),
|
||||||
|
|
||||||
|
secondary = tint(primaryColor, 0.4f),
|
||||||
|
onSecondary = Color.Black,
|
||||||
|
secondaryContainer = tint(primaryColor, 0.2f),
|
||||||
|
onSecondaryContainer = Color.Black,
|
||||||
|
|
||||||
|
tertiary = tint(primaryColor, 0.5f),
|
||||||
|
onTertiary = Color.Black,
|
||||||
|
tertiaryContainer = tint(primaryColor, 0.3f),
|
||||||
|
onTertiaryContainer = Color.Black,
|
||||||
|
|
||||||
|
background = background,
|
||||||
|
onBackground = Color.Black,
|
||||||
|
|
||||||
|
surface = surface,
|
||||||
|
onSurface = Color.Black,
|
||||||
|
|
||||||
|
surfaceVariant = tint(surface, 0.1f),
|
||||||
|
onSurfaceVariant = Color(0xFF49454F),
|
||||||
|
|
||||||
|
surfaceTint = primaryColor,
|
||||||
|
|
||||||
|
inverseSurface = Color(0xFF121212),
|
||||||
|
inverseOnSurface = Color.White,
|
||||||
|
|
||||||
|
error = Color(0xFFB00020),
|
||||||
|
onError = Color.White,
|
||||||
|
errorContainer = Color(0xFFFFDAD6),
|
||||||
|
onErrorContainer = Color.Black,
|
||||||
|
|
||||||
|
outline = Color(0xFF737373),
|
||||||
|
outlineVariant = Color(0xFFD0C4C9),
|
||||||
|
scrim = Color.Black,
|
||||||
|
|
||||||
|
surfaceBright = tint(surfaceBright, 0.1f),
|
||||||
|
surfaceContainer = tint(surfaceContainer, 0.1f),
|
||||||
|
surfaceContainerHigh = tint(surfaceContainerHigh, 0.12f),
|
||||||
|
surfaceContainerHighest = tint(surfaceContainerHighest, 0.15f),
|
||||||
|
surfaceContainerLow = tint(surfaceContainerLow, 0.08f),
|
||||||
|
surfaceContainerLowest = tint(surfaceContainerLowest, 0.05f),
|
||||||
|
surfaceDim = tint(surfaceDim, 0.05f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val DarkColorScheme = generateColorScheme(Color(0xFF36A0FF), isDarkMode = true)
|
||||||
|
private val LightColorScheme = generateColorScheme(Color(0xFF36A0FF), isDarkMode = false)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AetherTheme(
|
fun AetherTheme(
|
||||||
@@ -41,11 +141,6 @@ fun AetherTheme(
|
|||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
|
||||||
val context = LocalContext.current
|
|
||||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
darkTheme -> DarkColorScheme
|
darkTheme -> DarkColorScheme
|
||||||
else -> LightColorScheme
|
else -> LightColorScheme
|
||||||
}
|
}
|
||||||
|
|||||||
101
app/src/main/java/com/acitelight/aether/view/BiliStyle.kt
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
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.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
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
),
|
||||||
|
thumb = {
|
||||||
|
|
||||||
|
},
|
||||||
|
track = { sliderPositions ->
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.height(trackHeight)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(Color.LightGray.copy(alpha = 0.3f), RoundedCornerShape(50))
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.fillMaxWidth(value)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(colorScheme.primary, RoundedCornerShape(50))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
60
app/src/main/java/com/acitelight/aether/view/BookmarkPop.kt
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BookmarkPop(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var inputValue by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = {
|
||||||
|
Text("Bookmark", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = inputValue,
|
||||||
|
onValueChange = { inputValue = it },
|
||||||
|
label = { Text("Bookmark") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = { onConfirm(inputValue) },
|
||||||
|
enabled = inputValue.isNotBlank()
|
||||||
|
) {
|
||||||
|
Text("Confirm")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("Cancel")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
properties = DialogProperties(
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
dismissOnClickOutside = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
325
app/src/main/java/com/acitelight/aether/view/ComicGridView.kt
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import com.acitelight.aether.ToggleFullScreen
|
||||||
|
import com.acitelight.aether.model.BookMark
|
||||||
|
import com.acitelight.aether.model.Comic
|
||||||
|
import com.acitelight.aether.viewModel.ComicGridViewModel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ComicGridView(
|
||||||
|
comicId: String,
|
||||||
|
navController: NavHostController,
|
||||||
|
comicGridViewModel: ComicGridViewModel = hiltViewModel<ComicGridViewModel>()
|
||||||
|
) {
|
||||||
|
comicGridViewModel.resolve(comicId.hexToString())
|
||||||
|
comicGridViewModel.updateProcess(comicId.hexToString()) {}
|
||||||
|
ToggleFullScreen(false)
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
val comic by comicGridViewModel.comic
|
||||||
|
val record by comicGridViewModel.record
|
||||||
|
|
||||||
|
if (comic != null) {
|
||||||
|
Column {
|
||||||
|
Card(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 36.dp)
|
||||||
|
.heightIn(min = 42.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.heightIn(min = 42.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = comic!!.comic.comic_name,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier.padding(4.dp).align(Alignment.CenterStart)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
.heightIn(min = 42.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.heightIn(min = 42.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = comic!!.comic.author,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
.heightIn(min = 42.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.heightIn(min = 42.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = "Tags : ${comic!!.comic.tags.joinToString(", ")}",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 5,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.padding(top = 6.dp)
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
)
|
||||||
|
{
|
||||||
|
items(comicGridViewModel.chapterList)
|
||||||
|
{ c ->
|
||||||
|
ChapterCard(comic!!, navController, c, comicGridViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 6.dp)
|
||||||
|
.padding(bottom = 20.dp)
|
||||||
|
.height(42.dp)
|
||||||
|
.clickable {
|
||||||
|
comicGridViewModel.updateProcess(comicId.hexToString())
|
||||||
|
{
|
||||||
|
if (record != null) {
|
||||||
|
val k = comic!!.getPageChapterIndex(record!!.position)
|
||||||
|
val route = "comic_page_route/${comic!!.id.toHex()}/${
|
||||||
|
record!!.position
|
||||||
|
}"
|
||||||
|
navController.navigate(route)
|
||||||
|
} else {
|
||||||
|
val route = "comic_page_route/${comic!!.id.toHex()}/${0}"
|
||||||
|
navController.navigate(route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
if (record != null) {
|
||||||
|
val k = comic!!.getPageChapterIndex(record!!.position)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Last Read Position: ${k.first.name} ${k.second}/${
|
||||||
|
comic!!.getChapterLength(
|
||||||
|
k.first.page
|
||||||
|
)
|
||||||
|
}",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.weight(1f)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Read from scratch",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChapterCard(
|
||||||
|
comic: Comic,
|
||||||
|
navController: NavHostController,
|
||||||
|
chapter: BookMark,
|
||||||
|
comicGridViewModel: ComicGridViewModel = hiltViewModel<ComicGridViewModel>()
|
||||||
|
) {
|
||||||
|
val c = chapter
|
||||||
|
val iv = comic.getPageIndex(c.page)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
onClick = {
|
||||||
|
val route = "comic_page_route/${comic.id.toHex()}/${comic.getPageIndex(chapter.page)}"
|
||||||
|
navController.navigate(route)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth())
|
||||||
|
{
|
||||||
|
Row(Modifier.padding(6.dp))
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.heightIn(max = 170.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color(0x44FFFFFF))
|
||||||
|
)
|
||||||
|
{
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(comic.getPage(c.page))
|
||||||
|
.memoryCacheKey("${comic.id}/${c.page}")
|
||||||
|
.diskCacheKey("${comic.id}/${c.page}")
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
imageLoader = comicGridViewModel.imageLoader!!,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.widthIn(max = 170.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = chapter.name,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 5,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.background(Color.Transparent)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${comic.getChapterLength(chapter.page)} Pages",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.background(Color.Transparent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val r = comic.comic.list.subList(iv, iv + comic.getChapterLength(c.page))
|
||||||
|
LazyRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(6.dp)
|
||||||
|
) {
|
||||||
|
items(r)
|
||||||
|
{ r ->
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
.height(140.dp)
|
||||||
|
.padding(horizontal = 6.dp),
|
||||||
|
onClick = {
|
||||||
|
val route =
|
||||||
|
"comic_page_route/${"${comic.id}".toHex()}/${comic.getPageIndex(r)}"
|
||||||
|
navController.navigate(route)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(comic.getPage(r))
|
||||||
|
.memoryCacheKey("${comic.id}/${r}")
|
||||||
|
.diskCacheKey("${comic.id}/${r}")
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
imageLoader = comicGridViewModel.imageLoader!!,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(RoundedCornerShape(12.dp)),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
327
app/src/main/java/com/acitelight/aether/view/ComicPageView.kt
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Bookmarks
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import com.acitelight.aether.model.BookMark
|
||||||
|
import com.acitelight.aether.viewModel.ComicPageViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ComicPageView(
|
||||||
|
comicId: String,
|
||||||
|
page: String,
|
||||||
|
navController: NavHostController,
|
||||||
|
comicPageViewModel: ComicPageViewModel = hiltViewModel<ComicPageViewModel>()
|
||||||
|
) {
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
comicPageViewModel.Resolve(comicId.hexToString(), page.toInt())
|
||||||
|
|
||||||
|
val title by comicPageViewModel.title
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = page.toInt(),
|
||||||
|
pageCount = { comicPageViewModel.pageList.size })
|
||||||
|
var showPlane by comicPageViewModel.showPlane
|
||||||
|
var showBookMarkPop by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
comicPageViewModel.updateProcess(pagerState.currentPage)
|
||||||
|
|
||||||
|
val comic by comicPageViewModel.comic
|
||||||
|
comic?.let {
|
||||||
|
Box()
|
||||||
|
{
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.background(Color.Black)
|
||||||
|
.clickable {
|
||||||
|
showPlane = !showPlane
|
||||||
|
if (showPlane) {
|
||||||
|
comicPageViewModel.viewModelScope.launch {
|
||||||
|
comicPageViewModel.listState?.scrollToItem(index = pagerState.currentPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { page ->
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(it.getPage(page))
|
||||||
|
.memoryCacheKey("${it.id}/${page}")
|
||||||
|
.diskCacheKey("${it.id}/${page}")
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
imageLoader = comicPageViewModel.imageLoader!!,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = showPlane,
|
||||||
|
enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }),
|
||||||
|
exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
) {
|
||||||
|
Box()
|
||||||
|
{
|
||||||
|
Column(Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.fillMaxWidth())
|
||||||
|
{
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 18.dp)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.height(42.dp)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row(modifier = Modifier.fillMaxSize())
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
|
.weight(1f)
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${pagerState.currentPage + 1}/${pagerState.pageCount}",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.widthIn(min = 60.dp)
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxWidth()) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.padding(top = 6.dp)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.height(42.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row {
|
||||||
|
val k = it.getPageChapterIndex(pagerState.currentPage)
|
||||||
|
Text(
|
||||||
|
text = k.first.name,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.padding(horizontal = 10.dp)
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${k.second}/${it.getChapterLength(k.first.page)}",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.widthIn(min = 60.dp)
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.padding(top = 6.dp)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.height(42.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(Modifier.clickable {
|
||||||
|
showBookMarkPop = true
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Bookmarks,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp),
|
||||||
|
contentDescription = "Bookmark"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = showPlane,
|
||||||
|
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }),
|
||||||
|
exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box {
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
state = comicPageViewModel.listState!!, modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 18.dp)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
.height(240.dp)
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
items(comicPageViewModel.pageList.size)
|
||||||
|
{ r ->
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.primary.copy(0.8f)),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.wrapContentHeight()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
onClick = {
|
||||||
|
pagerState.requestScrollToPage(page = r)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Box(Modifier.padding(4.dp))
|
||||||
|
{
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(it.getPage(r))
|
||||||
|
.memoryCacheKey("${it.id}/${r}")
|
||||||
|
.diskCacheKey("${it.id}/${r}")
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
imageLoader = comicPageViewModel.imageLoader!!,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.align(Alignment.Center),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
)
|
||||||
|
val k = it.getPageChapterIndex(r)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.padding(6.dp)
|
||||||
|
.background(
|
||||||
|
Color.Black.copy(alpha = 0.65f),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row {
|
||||||
|
Text(
|
||||||
|
text = k.first.name,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(2.dp)
|
||||||
|
.widthIn(max = 200.dp)
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${k.second}/${it.getChapterLength(k.first.page)}",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(2.dp)
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showBookMarkPop) {
|
||||||
|
BookmarkPop({
|
||||||
|
showBookMarkPop = false
|
||||||
|
}, { s ->
|
||||||
|
showBookMarkPop = false
|
||||||
|
comicPageViewModel.viewModelScope.launch {
|
||||||
|
comicPageViewModel.mediaManager.postBookmark(
|
||||||
|
comicId.hexToString(),
|
||||||
|
BookMark(name = s, page = comicPageViewModel.pageList[pagerState.currentPage])
|
||||||
|
)
|
||||||
|
comicPageViewModel.comic.value =
|
||||||
|
comicPageViewModel.mediaManager.queryComicInfoSingle(comicId.hexToString())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,295 @@
|
|||||||
package com.acitelight.aether.view
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
import android.nfc.Tag
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.acitelight.aether.model.Video
|
||||||
|
import com.acitelight.aether.viewModel.VideoScreenViewModel
|
||||||
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
|
import androidx.compose.ui.layout.Placeable
|
||||||
|
import androidx.compose.ui.modifier.modifierLocalOf
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import com.acitelight.aether.Global
|
||||||
|
import com.acitelight.aether.model.Comic
|
||||||
import com.acitelight.aether.viewModel.ComicScreenViewModel
|
import com.acitelight.aether.viewModel.ComicScreenViewModel
|
||||||
|
import java.nio.charset.Charset
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ComicScreen(comicScreenViewModel: ComicScreenViewModel = viewModel())
|
fun VariableGrid(
|
||||||
{
|
modifier: Modifier = Modifier,
|
||||||
|
rowHeight: Dp,
|
||||||
|
horizontalSpacing: Dp = 4.dp,
|
||||||
|
verticalSpacing: Dp = 4.dp,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
Layout(
|
||||||
|
modifier = modifier
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
content = content
|
||||||
|
) { measurables, constraints ->
|
||||||
|
|
||||||
|
val rowHeightPx = rowHeight.roundToPx()
|
||||||
|
val hSpacePx = horizontalSpacing.roundToPx()
|
||||||
|
val vSpacePx = verticalSpacing.roundToPx()
|
||||||
|
|
||||||
|
val placeables = measurables.map { measurable ->
|
||||||
|
measurable.measure(
|
||||||
|
constraints.copy(
|
||||||
|
minWidth = 0,
|
||||||
|
minHeight = rowHeightPx,
|
||||||
|
maxHeight = rowHeightPx
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val rows = mutableListOf<List<Placeable>>()
|
||||||
|
var currentRow = mutableListOf<Placeable>()
|
||||||
|
var currentWidth = 0
|
||||||
|
val maxWidth = constraints.maxWidth
|
||||||
|
|
||||||
|
for (placeable in placeables) {
|
||||||
|
if (currentRow.isNotEmpty() && currentWidth + placeable.width + hSpacePx > maxWidth) {
|
||||||
|
rows.add(currentRow)
|
||||||
|
currentRow = mutableListOf()
|
||||||
|
currentWidth = 0
|
||||||
|
}
|
||||||
|
currentRow.add(placeable)
|
||||||
|
currentWidth += placeable.width + hSpacePx
|
||||||
|
}
|
||||||
|
if (currentRow.isNotEmpty()) {
|
||||||
|
rows.add(currentRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
val layoutHeight = if (rows.isEmpty()) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
rows.size * rowHeightPx + (rows.size - 1) * vSpacePx
|
||||||
|
}
|
||||||
|
|
||||||
|
layout(
|
||||||
|
width = constraints.maxWidth.coerceAtLeast(constraints.minWidth),
|
||||||
|
height = layoutHeight.coerceAtLeast(constraints.minHeight)
|
||||||
|
) {
|
||||||
|
var y = 0
|
||||||
|
for (row in rows) {
|
||||||
|
var x = 0
|
||||||
|
for (placeable in row) {
|
||||||
|
placeable.placeRelative(x, y)
|
||||||
|
x += placeable.width + hSpacePx
|
||||||
|
}
|
||||||
|
y += rowHeightPx + vSpacePx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ComicScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
comicScreenViewModel: ComicScreenViewModel = hiltViewModel<ComicScreenViewModel>()
|
||||||
|
) {
|
||||||
|
val included = comicScreenViewModel.included
|
||||||
|
val state = rememberLazyStaggeredGridState()
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
Column {
|
||||||
|
|
||||||
|
VariableGrid(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 120.dp)
|
||||||
|
.padding(8.dp),
|
||||||
|
rowHeight = 32.dp
|
||||||
|
)
|
||||||
|
{
|
||||||
|
for (i in comicScreenViewModel.tags) {
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.background(
|
||||||
|
if (included.contains(i)) Color.Green.copy(alpha = 0.65f) else colorScheme.primary,
|
||||||
|
shape = RoundedCornerShape(4.dp)
|
||||||
|
)
|
||||||
|
.height(32.dp).widthIn(max = 72.dp)
|
||||||
|
.clickable {
|
||||||
|
if (included.contains(i))
|
||||||
|
included.remove(i)
|
||||||
|
else
|
||||||
|
included.add(i)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = i,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(2.dp)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(thickness = 1.5.dp)
|
||||||
|
|
||||||
|
LazyVerticalStaggeredGrid(
|
||||||
|
columns = StaggeredGridCells.Adaptive(136.dp),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
verticalItemSpacing = 8.dp,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = comicScreenViewModel.comics.filter { x ->
|
||||||
|
included.all { y -> y in x.comic.tags } || included.isEmpty()
|
||||||
|
},
|
||||||
|
key = { it.id }
|
||||||
|
) { comic ->
|
||||||
|
Box(modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
) {
|
||||||
|
ComicCard(comic, navController, comicScreenViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ComicCard(
|
||||||
|
comic: Comic,
|
||||||
|
navController: NavHostController,
|
||||||
|
comicScreenViewModel: ComicScreenViewModel
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight(),
|
||||||
|
onClick = {
|
||||||
|
val route = "comic_grid_route/${"${comic.id}".toHex()}"
|
||||||
|
navController.navigate(route)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(comic.getPage(0))
|
||||||
|
.memoryCacheKey("${comic.id}/${0}")
|
||||||
|
.diskCacheKey("${comic.id}/${0}")
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
imageLoader = comicScreenViewModel.imageLoader!!,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(24.dp)
|
||||||
|
.background(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Black.copy(alpha = 0.45f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(2.dp),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
text = "${comic.comic.list.size} Pages",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = comic.comic.comic_name,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 2,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.background(Color.Transparent)
|
||||||
|
.heightIn(max = 48.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Id: ${comic.id}",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 2,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(bottom = 4.dp).padding(horizontal = 4.dp)
|
||||||
|
.background(Color.Transparent)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,68 +1,201 @@
|
|||||||
package com.acitelight.aether.view
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.DividerDefaults
|
import androidx.compose.material3.DividerDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
import com.acitelight.aether.Global
|
import androidx.navigation.NavHostController
|
||||||
import com.acitelight.aether.service.MediaManager
|
import coil3.compose.AsyncImage
|
||||||
import com.acitelight.aether.service.RecentManager
|
import coil3.request.ImageRequest
|
||||||
|
import com.acitelight.aether.Global.updateRelate
|
||||||
|
import com.acitelight.aether.model.Comic
|
||||||
|
import com.acitelight.aether.viewModel.ComicScreenViewModel
|
||||||
import com.acitelight.aether.viewModel.HomeScreenViewModel
|
import com.acitelight.aether.viewModel.HomeScreenViewModel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel(), navController: NavController)
|
fun HomeScreen(
|
||||||
|
homeScreenViewModel: HomeScreenViewModel = androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel<HomeScreenViewModel>(),
|
||||||
|
navController: NavHostController)
|
||||||
{
|
{
|
||||||
if(Global.loggedIn)
|
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
|
||||||
homeScreenViewModel.Init()
|
|
||||||
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth())
|
HorizontalPager(
|
||||||
{
|
state = pagerState,
|
||||||
item()
|
modifier = Modifier.fillMaxSize().background(Color.Black)
|
||||||
|
){
|
||||||
|
p ->
|
||||||
|
if(p == 0)
|
||||||
{
|
{
|
||||||
Column {
|
Column(Modifier.fillMaxHeight()) {
|
||||||
Text(
|
Text(
|
||||||
text = "Recent",
|
text = "Videos",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
modifier = Modifier.padding(16.dp).align(Alignment.Start)
|
modifier = Modifier.padding(8.dp).align(Alignment.Start)
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color)
|
HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color)
|
||||||
|
|
||||||
for(i in RecentManager.recent)
|
LazyColumn(modifier = Modifier.fillMaxWidth())
|
||||||
{
|
{
|
||||||
MiniVideoCard(
|
items(homeScreenViewModel.recentManager.recentVideo)
|
||||||
modifier = Modifier
|
{
|
||||||
.padding(horizontal = 12.dp),
|
i ->
|
||||||
i,
|
MiniVideoCard(
|
||||||
{
|
modifier = Modifier
|
||||||
Global.sameClassVideos = RecentManager.recent
|
.padding(horizontal = 12.dp),
|
||||||
val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }"
|
i,
|
||||||
navController.navigate(route)
|
{
|
||||||
}, homeScreenViewModel.imageLoader!!)
|
updateRelate(homeScreenViewModel.recentManager.recentVideo, i)
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color)
|
|
||||||
|
val playList = mutableListOf("${i.klass}/${i.id}")
|
||||||
|
val fv = homeScreenViewModel.videoLibrary.classesMap.map { it.value }.flatten()
|
||||||
|
|
||||||
|
val group = fv.filter { it.klass == i.klass && it.video.group == i.video.group }
|
||||||
|
for (i in group) {
|
||||||
|
playList.add("${i.klass}/${i.id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val route = "video_player_route/${playList.joinToString(",").toHex()}"
|
||||||
|
navController.navigate(route)
|
||||||
|
}, homeScreenViewModel.imageLoader!!)
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.4f), 1.dp, DividerDefaults.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Column(Modifier.fillMaxHeight()) {
|
||||||
|
Text(
|
||||||
|
text = "Comics",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
modifier = Modifier.padding(8.dp).align(Alignment.Start)
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color)
|
||||||
|
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(128.dp),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
items(homeScreenViewModel.recentManager.recentComic)
|
||||||
|
{
|
||||||
|
comic ->
|
||||||
|
ComicCardRecent(comic, navController, homeScreenViewModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ComicCardRecent(
|
||||||
|
comic: Comic,
|
||||||
|
navController: NavHostController,
|
||||||
|
homeScreenViewModel: HomeScreenViewModel
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight(),
|
||||||
|
onClick = {
|
||||||
|
val route = "comic_grid_route/${"${comic.id}".toHex()}"
|
||||||
|
navController.navigate(route)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(comic.getPage(0))
|
||||||
|
.memoryCacheKey("${comic.id}/${0}")
|
||||||
|
.diskCacheKey("${comic.id}/${0}")
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
imageLoader = homeScreenViewModel.imageLoader!!,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(24.dp)
|
||||||
|
.background(
|
||||||
|
brush = Brush.verticalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Transparent,
|
||||||
|
Color.Black.copy(alpha = 0.45f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(2.dp),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
text = "${comic.comic.list.size} Pages",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = comic.comic.comic_name,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 2,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.background(Color.Transparent)
|
||||||
|
.heightIn(48.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
package com.acitelight.aether.view
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -17,6 +20,7 @@ import androidx.compose.material.icons.filled.Person
|
|||||||
import androidx.compose.material.icons.filled.Security
|
import androidx.compose.material.icons.filled.Security
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -32,17 +36,25 @@ 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.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.acitelight.aether.service.ApiClient.api
|
||||||
import com.acitelight.aether.viewModel.MeScreenViewModel
|
import com.acitelight.aether.viewModel.MeScreenViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) {
|
fun MeScreen(meScreenViewModel: MeScreenViewModel = androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel<MeScreenViewModel>()) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var username by meScreenViewModel.username;
|
var username by meScreenViewModel.username;
|
||||||
var privateKey by meScreenViewModel.privateKey;
|
var privateKey by meScreenViewModel.privateKey;
|
||||||
var url by meScreenViewModel.url
|
var url by meScreenViewModel.url
|
||||||
var cert by meScreenViewModel.cert
|
var cert by meScreenViewModel.cert
|
||||||
|
|
||||||
|
val uss by meScreenViewModel.uss.collectAsState(initial = false)
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -104,7 +116,7 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) {
|
|||||||
// Save Button
|
// Save Button
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
meScreenViewModel.updateAccount(username, privateKey, context)
|
meScreenViewModel.updateAccount(username, privateKey)
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = privateKey != "******"
|
enabled = privateKey != "******"
|
||||||
@@ -134,6 +146,8 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) {
|
|||||||
.align(Alignment.Start)
|
.align(Alignment.Start)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
// Username input field
|
// Username input field
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = url,
|
value = url,
|
||||||
@@ -146,32 +160,63 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) {
|
|||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
// Private key input field
|
Row(Modifier.align(Alignment.Start)) {
|
||||||
OutlinedTextField(
|
Checkbox(
|
||||||
value = cert,
|
checked = uss,
|
||||||
onValueChange = { cert = it },
|
onCheckedChange = { isChecked ->
|
||||||
label = { Text("Cert") },
|
meScreenViewModel.onUseSelfSignedCheckedChange(isChecked)
|
||||||
singleLine = false,
|
},
|
||||||
maxLines = 40,
|
modifier = Modifier.align(Alignment.CenterVertically)
|
||||||
minLines = 20,
|
)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
textStyle = TextStyle(
|
Text(
|
||||||
fontSize = 8.sp
|
text = "Use Self-Signed Cert",
|
||||||
|
modifier = Modifier.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Private key input field
|
||||||
|
if (uss)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = cert,
|
||||||
|
onValueChange = { cert = it },
|
||||||
|
label = { Text("Cert") },
|
||||||
|
singleLine = false,
|
||||||
|
maxLines = 40,
|
||||||
|
minLines = 20,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textStyle = TextStyle(
|
||||||
|
fontSize = 8.sp
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
// Save Button
|
// Save Button
|
||||||
Button(
|
Row{
|
||||||
onClick = {
|
Button(
|
||||||
meScreenViewModel.updateServer(url, cert, context)
|
onClick = {
|
||||||
},
|
meScreenViewModel.updateServer(url, cert)
|
||||||
modifier = Modifier.fillMaxWidth()
|
},
|
||||||
) {
|
modifier = Modifier.weight(0.5f).padding(8.dp)
|
||||||
Text("Save")
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
meScreenViewModel.viewModelScope.launch {
|
||||||
|
Log.i("Delay Analyze", "Start Abyss Hello")
|
||||||
|
val h = api!!.hello()
|
||||||
|
Log.i("Delay Analyze", "Abyss Hello: ${h.string()}")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(0.5f).padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text("Ping")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
app/src/main/java/com/acitelight/aether/view/MiniVideoCard.kt
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit, imageLoader: ImageLoader) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.height(80.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
colors = CardColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
disabledContentColor = Color.Transparent,
|
||||||
|
disabledContainerColor = Color.Transparent
|
||||||
|
),
|
||||||
|
onClick = onClick
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row()
|
||||||
|
{
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(video.getCover())
|
||||||
|
.memoryCacheKey("${video.klass}/${video.id}/cover")
|
||||||
|
.diskCacheKey("${video.klass}/${video.id}/cover")
|
||||||
|
.listener(
|
||||||
|
onStart = { },
|
||||||
|
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,60 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
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.material3.Card
|
||||||
|
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.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 name by videoPlayerViewModel.currentName
|
||||||
|
|
||||||
|
LazyRow(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(80.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 24.dp)
|
||||||
|
) {
|
||||||
|
items(videoPlayerViewModel.videos) { it ->
|
||||||
|
// SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!)
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxHeight().width(140.dp),
|
||||||
|
onClick = {
|
||||||
|
if(name == it.video.name)
|
||||||
|
return@Card
|
||||||
|
|
||||||
|
videoPlayerViewModel.viewModelScope.launch {
|
||||||
|
videoPlayerViewModel.startPlay(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
409
app/src/main/java/com/acitelight/aether/view/PortalCorePlayer.kt
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.view.View
|
||||||
|
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.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.viewModel.VideoPlayerViewModel
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
|
||||||
|
@androidx.annotation.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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = videoPlayerViewModel.isLongPressing,
|
||||||
|
enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }),
|
||||||
|
exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(top = 24.dp)
|
||||||
|
.background(Color(0x44000000), RoundedCornerShape(18))
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.FastForward,
|
||||||
|
contentDescription = "Fast Forward",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "3X Speed...",
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(4.dp)
|
||||||
|
.align(Alignment.CenterVertically),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFFFFFFFF)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
visible = videoPlayerViewModel.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.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())
|
||||||
|
|
||||||
|
androidx.compose.animation.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitleOverlay(
|
||||||
|
cues = videoPlayerViewModel.cues,
|
||||||
|
modifier = Modifier.matchParentSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
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) {
|
||||||
|
android.text.Layout.Alignment.ALIGN_CENTER -> TextAlign.Center
|
||||||
|
android.text.Layout.Alignment.ALIGN_OPPOSITE -> TextAlign.End
|
||||||
|
android.text.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,326 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import com.acitelight.aether.Global.updateRelate
|
||||||
|
import com.acitelight.aether.model.VideoDownloadItemState
|
||||||
|
import com.acitelight.aether.model.Video
|
||||||
|
import com.acitelight.aether.viewModel.TransmissionScreenViewModel
|
||||||
|
import com.tonyodev.fetch2.Status
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TransmissionScreen(
|
||||||
|
navigator: NavHostController,
|
||||||
|
transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel<TransmissionScreenViewModel>()
|
||||||
|
) {
|
||||||
|
val downloads = transmissionScreenViewModel.downloads
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
items(downloads.filter { it.type == "main" }, key = { it.id }) { item ->
|
||||||
|
VideoDownloadCard(
|
||||||
|
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.cancel(i.id)
|
||||||
|
},
|
||||||
|
onDelete = {
|
||||||
|
for (i in downloadToGroup(
|
||||||
|
item,
|
||||||
|
downloads
|
||||||
|
)) transmissionScreenViewModel.delete(i.id, true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VideoDownloadCard(
|
||||||
|
navigator: NavHostController,
|
||||||
|
viewModel: TransmissionScreenViewModel,
|
||||||
|
model: VideoDownloadItemState,
|
||||||
|
onPause: () -> Unit,
|
||||||
|
onResume: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(4.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(8.dp)
|
||||||
|
.background(Color.Transparent)
|
||||||
|
.clickable(onClick = {
|
||||||
|
if (model.status == Status.COMPLETED) {
|
||||||
|
viewModel.viewModelScope.launch(Dispatchers.IO)
|
||||||
|
{
|
||||||
|
val downloaded = viewModel.fetchManager.getAllDownloadsAsync().filter {
|
||||||
|
it.status == Status.COMPLETED && it.extras.getString(
|
||||||
|
"isComic",
|
||||||
|
""
|
||||||
|
) != "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsonQuery = downloaded.map {
|
||||||
|
File(
|
||||||
|
viewModel.context.getExternalFilesDir(null),
|
||||||
|
"videos/${
|
||||||
|
it.extras.getString(
|
||||||
|
"class",
|
||||||
|
""
|
||||||
|
)
|
||||||
|
}/${it.extras.getString("id", "")}/summary.json"
|
||||||
|
).readText()
|
||||||
|
}
|
||||||
|
.map {
|
||||||
|
Json.decodeFromString<Video>(it)
|
||||||
|
.toLocal(viewModel.context.getExternalFilesDir(null)!!.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRelate(
|
||||||
|
jsonQuery,
|
||||||
|
jsonQuery.first { it.id == model.vid && it.klass == model.klass }
|
||||||
|
)
|
||||||
|
|
||||||
|
val playList = mutableListOf("${model.klass}/${model.vid}")
|
||||||
|
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) {
|
||||||
|
playList.add("${i.klass}/${i.id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val route = "video_player_route/${playList.joinToString(",").toHex()}"
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
navigator.navigate(route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = model.fileName, style = MaterialTheme.typography.titleMedium)
|
||||||
|
// Text(text = model.filePath, style = MaterialTheme.typography.titleSmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 5.dp)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.align(Alignment.CenterStart)
|
||||||
|
) {
|
||||||
|
val video = viewModel.modelToVideo(model)
|
||||||
|
|
||||||
|
if (video == null)
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(
|
||||||
|
File(
|
||||||
|
viewModel.context.getExternalFilesDir(null),
|
||||||
|
"videos/${model.klass}/${model.vid}/cover.jpg"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.memoryCacheKey("${model.klass}/${model.vid}/cover")
|
||||||
|
.diskCacheKey("${model.klass}/${model.vid}/cover")
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.height(100.dp),
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(video.getCover())
|
||||||
|
.memoryCacheKey("${model.klass}/${model.vid}/cover")
|
||||||
|
.diskCacheKey("${model.klass}/${model.vid}/cover")
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.height(100.dp),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
imageLoader = viewModel.imageLoader!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.align(Alignment.BottomEnd)) {
|
||||||
|
Text(
|
||||||
|
text = "${model.progress}%",
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.align(Alignment.End)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 8.dp)
|
||||||
|
.align(Alignment.End),
|
||||||
|
text = "%.2f MB/%.2f MB".format(
|
||||||
|
model.downloadedBytes / (1024.0 * 1024.0),
|
||||||
|
model.totalBytes / (1024.0 * 1024.0)
|
||||||
|
),
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// progress bar
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { model.progress.coerceIn(0, 100) / 100f },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp, bottom = 8.dp),
|
||||||
|
color = ProgressIndicatorDefaults.linearColor,
|
||||||
|
trackColor = ProgressIndicatorDefaults.linearTrackColor,
|
||||||
|
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
|
||||||
|
)
|
||||||
|
|
||||||
|
// action buttons
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
when (model.status) {
|
||||||
|
Status.DOWNLOADING -> {
|
||||||
|
Button(onClick = onPause) {
|
||||||
|
Icon(imageVector = Icons.Default.Pause, contentDescription = "Pause")
|
||||||
|
Text(text = " Pause", modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
Button(onClick = onCancel) {
|
||||||
|
Icon(imageVector = Icons.Default.Stop, contentDescription = "Cancel")
|
||||||
|
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Status.PAUSED, Status.QUEUED -> {
|
||||||
|
Button(onClick = onResume) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayArrow,
|
||||||
|
contentDescription = "Resume"
|
||||||
|
)
|
||||||
|
Text(text = " Resume", modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
Button(onClick = onCancel) {
|
||||||
|
Icon(imageVector = Icons.Default.Stop, contentDescription = "Cancel")
|
||||||
|
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Status.COMPLETED -> {
|
||||||
|
Button(onClick = onDelete) {
|
||||||
|
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
|
||||||
|
Text(text = " Delete", modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
// for FAILED, CANCELLED, REMOVED etc.
|
||||||
|
Button(onClick = onResume) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PlayArrow,
|
||||||
|
contentDescription = "Retry"
|
||||||
|
)
|
||||||
|
Text(text = " Retry", modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
Button(onClick = onDelete) {
|
||||||
|
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
|
||||||
|
Text(text = " Delete", modifier = Modifier.padding(start = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadToGroup(
|
||||||
|
i: VideoDownloadItemState,
|
||||||
|
downloads: List<VideoDownloadItemState>
|
||||||
|
): List<VideoDownloadItemState> {
|
||||||
|
return downloads.filter { it.vid == i.vid && it.klass == i.klass }
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.view.View
|
||||||
|
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.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.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.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
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.ToggleFullScreen
|
||||||
|
import com.acitelight.aether.viewModel.VideoPlayerViewModel
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
|
||||||
|
@androidx.annotation.OptIn(UnstableApi::class)
|
||||||
|
@Composable
|
||||||
|
fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val activity = (context as? Activity)!!
|
||||||
|
val exoPlayer: ExoPlayer = videoPlayerViewModel.player!!;
|
||||||
|
|
||||||
|
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
||||||
|
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
|
||||||
|
var volFactor by remember {
|
||||||
|
mutableFloatStateOf(
|
||||||
|
audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume.toFloat()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val name by videoPlayerViewModel.currentName
|
||||||
|
|
||||||
|
fun setVolume(value: Int) {
|
||||||
|
audioManager.setStreamVolume(
|
||||||
|
AudioManager.STREAM_MUSIC,
|
||||||
|
value.coerceIn(0, maxVolume),
|
||||||
|
AudioManager.FLAG_PLAY_SOUND
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ToggleFullScreen(true)
|
||||||
|
Box(Modifier.fillMaxSize())
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(Color.Black)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
AndroidView(
|
||||||
|
factory = {
|
||||||
|
PlayerView(
|
||||||
|
it
|
||||||
|
).apply {
|
||||||
|
player = exoPlayer
|
||||||
|
useController = false
|
||||||
|
subtitleView?.let { sv ->
|
||||||
|
sv.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
androidx.compose.animation.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).toLong()
|
||||||
|
)
|
||||||
|
}",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
androidx.compose.animation.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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 46.dp).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).toLong()
|
||||||
|
)
|
||||||
|
}",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
BiliStyleSlider(
|
||||||
|
value = videoPlayerViewModel.playProcess,
|
||||||
|
onValueChange = { value ->
|
||||||
|
exoPlayer.seekTo((exoPlayer.duration * value).toLong())
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.height(16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
.align(Alignment.Start),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
|
||||||
|
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
|
||||||
|
},
|
||||||
|
Modifier.size(42.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||||
|
contentDescription = "Play/Pause",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(42.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubtitleOverlay(
|
||||||
|
cues = videoPlayerViewModel.cues,
|
||||||
|
modifier = Modifier.matchParentSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
|
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.ToggleFullScreen
|
||||||
|
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
|
||||||
|
|
||||||
|
ToggleFullScreen(false)
|
||||||
|
Column(Modifier
|
||||||
|
.nestedScroll(nestedScrollConnection)
|
||||||
|
.fillMaxHeight())
|
||||||
|
{
|
||||||
|
Box {
|
||||||
|
PortalCorePlayer(
|
||||||
|
Modifier
|
||||||
|
.padding(top = 32.dp)
|
||||||
|
.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,
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
{
|
||||||
|
videoPlayerViewModel.isPlaying = false
|
||||||
|
videoPlayerViewModel.player?.pause()
|
||||||
|
val route = "video_player_route/${"${i.klass}/${i.id}".toHex()}"
|
||||||
|
navController.navigate(route)
|
||||||
|
}, videoPlayerViewModel.imageLoader!!
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
Modifier
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
.alpha(0.25f),
|
||||||
|
1.dp,
|
||||||
|
DividerDefaults.color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,55 @@
|
|||||||
package com.acitelight.aether.view
|
package com.acitelight.aether.view
|
||||||
|
|
||||||
import android.R.id.tabs
|
import android.widget.Toast
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.items
|
||||||
|
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CheckboxDefaults.colors
|
||||||
|
import androidx.compose.material3.DividerDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.PrimaryScrollableTabRow
|
|
||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRow
|
|
||||||
import androidx.compose.material3.TabRowDefaults
|
|
||||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -39,20 +63,42 @@ import androidx.compose.ui.unit.sp
|
|||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.acitelight.aether.model.Video
|
import com.acitelight.aether.model.Video
|
||||||
import com.acitelight.aether.service.MediaManager
|
|
||||||
import com.acitelight.aether.viewModel.VideoScreenViewModel
|
import com.acitelight.aether.viewModel.VideoScreenViewModel
|
||||||
import androidx.compose.material3.PrimaryTabRow
|
|
||||||
import androidx.compose.material3.ScrollableTabRow
|
import androidx.compose.material3.ScrollableTabRow
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.min
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
|
import com.acitelight.aether.CardPage
|
||||||
import com.acitelight.aether.Global
|
import com.acitelight.aether.Global
|
||||||
import kotlinx.coroutines.flow.first
|
import com.acitelight.aether.Global.updateRelate
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
|
import java.security.KeyPair
|
||||||
|
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, token = it.token, 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) }
|
||||||
@@ -70,67 +116,255 @@ fun String.hexToString(charset: Charset = Charsets.UTF_8): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navController: NavHostController)
|
fun VideoScreen(
|
||||||
{
|
videoScreenViewModel: VideoScreenViewModel = hiltViewModel<VideoScreenViewModel>(),
|
||||||
videoScreenViewModel.SetupClient()
|
navController: NavHostController
|
||||||
|
) {
|
||||||
|
val state = rememberLazyStaggeredGridState()
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val tabIndex by videoScreenViewModel.tabIndex
|
||||||
|
var menuVisibility by videoScreenViewModel.menuVisibility
|
||||||
|
var searchFilter by videoScreenViewModel.searchFilter
|
||||||
|
var doneInit by videoScreenViewModel.doneInit
|
||||||
|
val vb = videoTOView(videoScreenViewModel.videoLibrary.classesMap.getOrDefault(
|
||||||
|
videoScreenViewModel.videoLibrary.classes.getOrNull(
|
||||||
|
tabIndex
|
||||||
|
), listOf()
|
||||||
|
).filter { it.video.name.contains(searchFilter) }).filter { it.key != null }
|
||||||
|
.map{ i -> Pair(i.key!!, i.value.sortedWith(compareBy(naturalOrder()) { it.video.name }) ) }
|
||||||
|
.toList()
|
||||||
|
|
||||||
Column(
|
if (doneInit)
|
||||||
modifier = Modifier.fillMaxSize() // 或至少 fillMaxWidth()
|
CardPage(title = "Videos") {
|
||||||
){
|
Box(Modifier.fillMaxSize())
|
||||||
TopRow(videoScreenViewModel);
|
{
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
// TopRow(videoScreenViewModel);
|
||||||
|
Row(Modifier.padding(bottom = 4.dp))
|
||||||
|
{
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterVertically)
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.size(36.dp),
|
||||||
|
onClick = {
|
||||||
|
menuVisibility = !menuVisibility
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxSize())
|
||||||
|
{
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(30.dp)
|
||||||
|
.align(Alignment.Center),
|
||||||
|
imageVector = Icons.Default.Menu,
|
||||||
|
contentDescription = "Catalogue"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LazyVerticalGrid(
|
Card(
|
||||||
columns = GridCells.Fixed(2),
|
shape = RoundedCornerShape(8.dp),
|
||||||
contentPadding = PaddingValues(8.dp),
|
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
modifier = Modifier
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
.align(Alignment.CenterVertically)
|
||||||
)
|
.padding(horizontal = 2.dp)
|
||||||
{
|
.height(36.dp),
|
||||||
items(videoScreenViewModel.videos) { video ->
|
onClick = {
|
||||||
VideoCard(video, navController, videoScreenViewModel)
|
menuVisibility = !menuVisibility
|
||||||
|
})
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxHeight())
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = videoScreenViewModel.videoLibrary.classes.getOrNull(
|
||||||
|
tabIndex
|
||||||
|
)
|
||||||
|
?: "",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterStart)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(36.dp)
|
||||||
|
.widthIn(max = 240.dp)
|
||||||
|
.background(colorScheme.primary, RoundedCornerShape(8.dp))
|
||||||
|
.padding(horizontal = 6.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(30.dp)
|
||||||
|
.align(Alignment.CenterVertically),
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = "Catalogue"
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
BasicTextField(
|
||||||
|
value = searchFilter,
|
||||||
|
onValueChange = { searchFilter = it },
|
||||||
|
textStyle = LocalTextStyle.current.copy(
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Start
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.align(Alignment.CenterVertically)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HorizontalDivider(
|
||||||
|
Modifier.padding(bottom = 8.dp),
|
||||||
|
1.5.dp,
|
||||||
|
DividerDefaults.color
|
||||||
|
)
|
||||||
|
LazyVerticalStaggeredGrid(
|
||||||
|
columns = StaggeredGridCells.Adaptive(160.dp),
|
||||||
|
contentPadding = PaddingValues(8.dp),
|
||||||
|
verticalItemSpacing = 8.dp,
|
||||||
|
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(
|
||||||
|
8.dp
|
||||||
|
),
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = vb,
|
||||||
|
key = { "${it.first}/${it.second}" }
|
||||||
|
) { video ->
|
||||||
|
androidx.compose.foundation.layout.Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.wrapContentHeight()
|
||||||
|
) {
|
||||||
|
if(video.second.isNotEmpty())
|
||||||
|
VideoCard(video.second, navController, videoScreenViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = menuVisibility,
|
||||||
|
enter = slideInHorizontally(initialOffsetX = { full -> full }),
|
||||||
|
exit = slideOutHorizontally(targetOffsetX = { full -> full }),
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
Modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(250.dp)
|
||||||
|
.align(Alignment.CenterEnd),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.surface)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
LazyColumn {
|
||||||
|
items(videoScreenViewModel.videoLibrary.classes) { item ->
|
||||||
|
CatalogueItemRow(
|
||||||
|
item = Pair(
|
||||||
|
videoScreenViewModel.videoLibrary.classes.indexOf(item),
|
||||||
|
item
|
||||||
|
),
|
||||||
|
onItemClick = {
|
||||||
|
menuVisibility = false
|
||||||
|
videoScreenViewModel.setTabIndex(
|
||||||
|
videoScreenViewModel.videoLibrary.classes.indexOf(
|
||||||
|
item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun TopRow(videoScreenViewModel: VideoScreenViewModel)
|
|
||||||
{
|
|
||||||
val tabIndex by videoScreenViewModel.tabIndex;
|
|
||||||
val klasses by videoScreenViewModel.klasses.collectAsState();
|
|
||||||
|
|
||||||
if(klasses.isEmpty()) return
|
|
||||||
|
|
||||||
ScrollableTabRow (selectedTabIndex = tabIndex) {
|
|
||||||
klasses.forEachIndexed { index, title ->
|
|
||||||
Tab(
|
|
||||||
selected = tabIndex == index,
|
|
||||||
onClick = { videoScreenViewModel.setTabIndex(index) },
|
|
||||||
text = { Text(text = title, maxLines = 1) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VideoCard(video: Video, navController: NavHostController, videoScreenViewModel: VideoScreenViewModel) {
|
fun CatalogueItemRow(
|
||||||
|
item: Pair<Int, String>,
|
||||||
|
onItemClick: (Pair<Int, String>) -> Unit
|
||||||
|
) {
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable { onItemClick(item) }
|
||||||
|
.padding(4.dp)
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.heightIn(min = 28.dp)
|
||||||
|
.width(250.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = colorScheme.primary)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = item.second,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VideoCard(
|
||||||
|
videos: List<Video>,
|
||||||
|
navController: NavHostController,
|
||||||
|
videoScreenViewModel: VideoScreenViewModel
|
||||||
|
) {
|
||||||
|
val tabIndex by videoScreenViewModel.tabIndex;
|
||||||
|
val video = videos.first()
|
||||||
Card(
|
Card(
|
||||||
shape = RoundedCornerShape(6.dp),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.wrapContentHeight(),
|
.wrapContentHeight()
|
||||||
onClick = {
|
.combinedClickable(
|
||||||
Global.sameClassVideos = videoScreenViewModel.videos
|
onClick = {
|
||||||
val route = "video_player_route/${ "${video.klass}/${video.id}".toHex() }"
|
updateRelate(
|
||||||
navController.navigate(route)
|
videoScreenViewModel.videoLibrary.classesMap[videoScreenViewModel.videoLibrary.classes[tabIndex]]
|
||||||
}
|
?: mutableStateListOf(), video
|
||||||
|
)
|
||||||
|
val 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Box(modifier = Modifier.fillMaxSize()){
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = ImageRequest.Builder(LocalContext.current)
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
.data(video.getCover())
|
.data(video.getCover())
|
||||||
@@ -140,40 +374,84 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod
|
|||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Fit,
|
||||||
imageLoader = videoScreenViewModel.imageLoader!!
|
imageLoader = videoScreenViewModel.imageLoader!!
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
|
||||||
modifier = Modifier.align(Alignment.BottomEnd).padding(2.dp),
|
|
||||||
text = formatTime(video.video.duration), fontSize = 12.sp, color = Color.White, fontWeight = FontWeight.Bold)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(24.dp)
|
.height(24.dp)
|
||||||
.background( brush = Brush.verticalGradient(
|
.background(
|
||||||
colors = listOf(
|
brush = Brush.verticalGradient(
|
||||||
Color.Transparent,
|
colors = listOf(
|
||||||
Color.Black.copy(alpha = 0.45f)
|
Color.Transparent,
|
||||||
|
Color.Black.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
))
|
)
|
||||||
.align(Alignment.BottomCenter))
|
.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(
|
||||||
text = video.video.name,
|
text = video.video.group ?: video.video.name,
|
||||||
fontSize = 14.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
maxLines = 2,
|
maxLines = 4,
|
||||||
modifier = Modifier.padding(8.dp).background(Color.Transparent).heightIn(48.dp)
|
modifier = Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.background(Color.Transparent)
|
||||||
|
.heightIn(min = 24.dp),
|
||||||
|
lineHeight = 14.sp
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Text("Class", fontSize = 12.sp)
|
Text("Class: ", fontSize = 10.sp, maxLines = 1)
|
||||||
Text("${video.klass}", fontSize = 12.sp)
|
Text(video.klass, fontSize = 10.sp, maxLines = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.acitelight.aether.viewModel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.acitelight.aether.model.BookMark
|
||||||
|
import com.acitelight.aether.model.Comic
|
||||||
|
import com.acitelight.aether.model.ComicRecord
|
||||||
|
import com.acitelight.aether.model.ComicRecordDatabase
|
||||||
|
import com.acitelight.aether.service.ApiClient.createOkHttp
|
||||||
|
import com.acitelight.aether.service.MediaManager
|
||||||
|
import com.acitelight.aether.service.RecentManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ComicGridViewModel @Inject constructor(
|
||||||
|
@ApplicationContext val context: Context,
|
||||||
|
val mediaManager: MediaManager,
|
||||||
|
val recentManager: RecentManager
|
||||||
|
) : ViewModel()
|
||||||
|
{
|
||||||
|
var imageLoader: ImageLoader? = null
|
||||||
|
var comic = mutableStateOf<Comic?>(null)
|
||||||
|
val chapterList = mutableStateListOf<BookMark>()
|
||||||
|
var db: ComicRecordDatabase? = null
|
||||||
|
var record = mutableStateOf<ComicRecord?>(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
imageLoader = ImageLoader.Builder(context)
|
||||||
|
.components {
|
||||||
|
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
db = try{
|
||||||
|
ComicRecordDatabase.getDatabase(context)
|
||||||
|
}catch (e: Exception) {
|
||||||
|
print(e.message)
|
||||||
|
} as ComicRecordDatabase?
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolve(id: String)
|
||||||
|
{
|
||||||
|
viewModelScope.launch {
|
||||||
|
if(comic.value == null) {
|
||||||
|
comic.value = mediaManager.queryComicInfoSingle(id)
|
||||||
|
recentManager.pushComic(context, id)
|
||||||
|
val c = comic.value!!
|
||||||
|
for (i in c.comic.bookmarks) {
|
||||||
|
chapterList.add(i)
|
||||||
|
}
|
||||||
|
}else comic.value = mediaManager.queryComicInfoSingle(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProcess(id: String, callback: () -> Unit)
|
||||||
|
{
|
||||||
|
viewModelScope.launch {
|
||||||
|
record.value = db?.userDao()?.getById(id.toInt())
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.acitelight.aether.viewModel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.acitelight.aether.model.Comic
|
||||||
|
import com.acitelight.aether.model.ComicRecord
|
||||||
|
import com.acitelight.aether.model.ComicRecordDatabase
|
||||||
|
import com.acitelight.aether.service.ApiClient.createOkHttp
|
||||||
|
import com.acitelight.aether.service.MediaManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ComicPageViewModel @Inject constructor(
|
||||||
|
val mediaManager: MediaManager,
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) : ViewModel()
|
||||||
|
{
|
||||||
|
var imageLoader: ImageLoader? = null
|
||||||
|
var comic = mutableStateOf<Comic?>(null)
|
||||||
|
var pageList = mutableStateListOf<String>()
|
||||||
|
var title = mutableStateOf<String>("")
|
||||||
|
var listState: LazyListState? = null
|
||||||
|
var showPlane = mutableStateOf(true)
|
||||||
|
var db: ComicRecordDatabase
|
||||||
|
|
||||||
|
|
||||||
|
init{
|
||||||
|
imageLoader = ImageLoader.Builder(context)
|
||||||
|
.components {
|
||||||
|
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
listState = LazyListState(0, 0)
|
||||||
|
db = ComicRecordDatabase.getDatabase(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Resolve(id: String, page: Int)
|
||||||
|
{
|
||||||
|
if(comic.value != null) return
|
||||||
|
LaunchedEffect(id, page) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
comic.value = mediaManager.queryComicInfoSingle(id)
|
||||||
|
comic.value?.let {
|
||||||
|
pageList.addAll(it.comic.list)
|
||||||
|
title.value = it.comic.comic_name
|
||||||
|
listState?.scrollToItem(index = page)
|
||||||
|
updateProcess(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProcess(page: Int)
|
||||||
|
{
|
||||||
|
if(comic.value == null) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
db.userDao().insert(ComicRecord(id = comic.value!!.id.toInt(), name = comic.value!!.comic.comic_name, position = page))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,73 @@
|
|||||||
package com.acitelight.aether.viewModel
|
package com.acitelight.aether.viewModel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import com.acitelight.aether.model.Comic
|
import com.acitelight.aether.model.Comic
|
||||||
import com.acitelight.aether.model.Video
|
import com.acitelight.aether.model.ComicResponse
|
||||||
|
import com.acitelight.aether.service.ApiClient.createOkHttp
|
||||||
import com.acitelight.aether.service.MediaManager
|
import com.acitelight.aether.service.MediaManager
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ComicScreenViewModel : ViewModel()
|
@HiltViewModel
|
||||||
{
|
class ComicScreenViewModel @Inject constructor(
|
||||||
private val _comics = MutableStateFlow<List<Comic>>(emptyList())
|
@ApplicationContext private val context: Context,
|
||||||
val comics: StateFlow<List<Comic>> = _comics
|
val mediaManager: MediaManager
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
init
|
var imageLoader: ImageLoader? = null;
|
||||||
{
|
|
||||||
// viewModelScope.launch {
|
val comics = mutableStateListOf<Comic>()
|
||||||
// val l = MediaManager.listComics()
|
val excluded = mutableStateListOf<String>()
|
||||||
// _comics.value = l.map { MediaManager.queryComicInfo(it) }
|
val included = mutableStateListOf<String>()
|
||||||
// }
|
val tags = mutableStateListOf<String>()
|
||||||
|
private val counter = mutableMapOf<String, Int>()
|
||||||
|
|
||||||
|
fun insertItem(newItem: String) {
|
||||||
|
val newCount = (counter[newItem] ?: 0) + 1
|
||||||
|
counter[newItem] = newCount
|
||||||
|
|
||||||
|
if (newItem !in tags) {
|
||||||
|
val insertIndex = tags.indexOfFirst { counter[it]!! < newCount }
|
||||||
|
.takeIf { it >= 0 } ?: tags.size
|
||||||
|
tags.add(insertIndex, newItem)
|
||||||
|
} else {
|
||||||
|
var currentIndex = tags.indexOf(newItem)
|
||||||
|
while (currentIndex > 0 && counter[tags[currentIndex - 1]]!! < newCount) {
|
||||||
|
tags[currentIndex] = tags[currentIndex - 1]
|
||||||
|
tags[currentIndex - 1] = newItem
|
||||||
|
currentIndex--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
imageLoader = ImageLoader.Builder(context)
|
||||||
|
.components {
|
||||||
|
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val l = mediaManager.listComics()
|
||||||
|
val m = mediaManager.queryComicInfoBulk(l)
|
||||||
|
|
||||||
|
if(m != null) {
|
||||||
|
comics.addAll(m.sortedWith(compareBy(naturalOrder()) { it.comic.comic_name }))
|
||||||
|
tags.addAll(m.flatMap { it.comic.tags }.groupingBy { it }.eachCount()
|
||||||
|
.entries.sortedByDescending { it.value }
|
||||||
|
.map { it.key })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,105 +1,37 @@
|
|||||||
package com.acitelight.aether.viewModel
|
package com.acitelight.aether.viewModel
|
||||||
|
|
||||||
import android.app.Application
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.compose.runtime.State
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import com.acitelight.aether.Global
|
|
||||||
import com.acitelight.aether.dataStore
|
|
||||||
import com.acitelight.aether.model.Video
|
|
||||||
import com.acitelight.aether.model.VideoQueryIndex
|
|
||||||
import com.acitelight.aether.service.ApiClient
|
|
||||||
import com.acitelight.aether.service.ApiClient.createOkHttp
|
import com.acitelight.aether.service.ApiClient.createOkHttp
|
||||||
import com.acitelight.aether.service.AuthManager
|
|
||||||
import com.acitelight.aether.service.MediaManager
|
|
||||||
import com.acitelight.aether.service.MediaManager.token
|
|
||||||
import com.acitelight.aether.service.RecentManager
|
import com.acitelight.aether.service.RecentManager
|
||||||
import kotlinx.coroutines.flow.Flow
|
import com.acitelight.aether.service.VideoLibrary
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
|
||||||
class HomeScreenViewModel(application: Application) : AndroidViewModel(application)
|
|
||||||
|
@HiltViewModel
|
||||||
|
class HomeScreenViewModel @Inject constructor(
|
||||||
|
val recentManager: RecentManager,
|
||||||
|
@ApplicationContext val context: Context,
|
||||||
|
val videoLibrary: VideoLibrary,
|
||||||
|
) : ViewModel()
|
||||||
{
|
{
|
||||||
private val dataStore = application.dataStore
|
var imageLoader: ImageLoader? = null
|
||||||
private val USER_NAME_KEY = stringPreferencesKey("user_name")
|
|
||||||
private val PRIVATE_KEY = stringPreferencesKey("private_key")
|
|
||||||
private val URL_KEY = stringPreferencesKey("url")
|
|
||||||
private val CERT_KEY = stringPreferencesKey("cert")
|
|
||||||
|
|
||||||
val userNameFlow: Flow<String> = dataStore.data.map { preferences ->
|
init{
|
||||||
preferences[USER_NAME_KEY] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val privateKeyFlow: Flow<String> = dataStore.data.map { preferences ->
|
|
||||||
preferences[PRIVATE_KEY] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val urlFlow: Flow<String> = dataStore.data.map { preferences ->
|
|
||||||
preferences[URL_KEY] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val certFlow: Flow<String> = dataStore.data.map { preferences ->
|
|
||||||
preferences[CERT_KEY] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var _init = false
|
|
||||||
var imageLoader: ImageLoader? = null;
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Init(){
|
|
||||||
if(_init) return
|
|
||||||
_init = true
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
imageLoader = ImageLoader.Builder(context)
|
imageLoader = ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
remember {
|
|
||||||
viewModelScope.launch {
|
|
||||||
RecentManager.Query(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val u = userNameFlow.first()
|
recentManager.queryVideo(context)
|
||||||
val p = privateKeyFlow.first()
|
recentManager.queryComic(context)
|
||||||
val ur = urlFlow.first()
|
|
||||||
val c = certFlow.first()
|
|
||||||
|
|
||||||
if(u=="" || p=="" || ur=="" || c=="") return@launch
|
|
||||||
|
|
||||||
try{
|
|
||||||
ApiClient.apply(ur, c)
|
|
||||||
|
|
||||||
if (MediaManager.token == "null")
|
|
||||||
MediaManager.token = AuthManager.fetchToken(
|
|
||||||
u,
|
|
||||||
p
|
|
||||||
)!!
|
|
||||||
|
|
||||||
Global.loggedIn = true
|
|
||||||
}catch(e: Exception)
|
|
||||||
{
|
|
||||||
Global.loggedIn = false
|
|
||||||
print(e.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,10 +5,13 @@ import android.content.Context
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.core.net.toUri
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.acitelight.aether.AetherApp
|
||||||
import com.acitelight.aether.Global
|
import com.acitelight.aether.Global
|
||||||
import com.acitelight.aether.dataStore
|
import com.acitelight.aether.dataStore
|
||||||
import com.acitelight.aether.model.Video
|
import com.acitelight.aether.model.Video
|
||||||
@@ -20,103 +23,127 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import com.acitelight.aether.service.*
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class MeScreenViewModel(application: Application) : AndroidViewModel(application) {
|
@HiltViewModel
|
||||||
private val dataStore = application.dataStore
|
class MeScreenViewModel @Inject constructor(
|
||||||
private val USER_NAME_KEY = stringPreferencesKey("user_name")
|
private val settingsDataStoreManager: SettingsDataStoreManager,
|
||||||
private val PRIVATE_KEY = stringPreferencesKey("private_key")
|
@ApplicationContext private val context: Context,
|
||||||
private val URL_KEY = stringPreferencesKey("url")
|
val mediaManager: MediaManager
|
||||||
private val CERT_KEY = stringPreferencesKey("cert")
|
) : ViewModel() {
|
||||||
|
|
||||||
val userNameFlow: Flow<String> = dataStore.data.map { preferences ->
|
|
||||||
preferences[USER_NAME_KEY] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val privateKeyFlow: Flow<String> = dataStore.data.map { preferences ->
|
|
||||||
preferences[PRIVATE_KEY] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val urlFlow: Flow<String> = dataStore.data.map { preferences ->
|
|
||||||
preferences[URL_KEY] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val certFlow: Flow<String> = dataStore.data.map { preferences ->
|
|
||||||
preferences[CERT_KEY] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val username = mutableStateOf("");
|
val username = mutableStateOf("");
|
||||||
val privateKey = mutableStateOf("")
|
val privateKey = mutableStateOf("")
|
||||||
val url = mutableStateOf("");
|
val url = mutableStateOf("");
|
||||||
val cert = mutableStateOf("")
|
val cert = mutableStateOf("")
|
||||||
|
|
||||||
|
val uss = settingsDataStoreManager.useSelfSignedFlow
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
username.value = userNameFlow.first()
|
username.value = settingsDataStoreManager.userNameFlow.first()
|
||||||
privateKey.value = if (privateKeyFlow.first() == "") "" else "******"
|
privateKey.value = if (settingsDataStoreManager.privateKeyFlow.first() == "") "" else "******"
|
||||||
url.value = urlFlow.first()
|
url.value = settingsDataStoreManager.urlFlow.first()
|
||||||
cert.value = certFlow.first()
|
cert.value = settingsDataStoreManager.certFlow.first()
|
||||||
|
|
||||||
|
if(username.value=="" || privateKey.value=="" || url.value=="") return@launch
|
||||||
|
|
||||||
|
try{
|
||||||
|
val usedUrl = ApiClient.apply(context, url.value, if(uss.first()) cert.value else "")
|
||||||
|
|
||||||
|
if (mediaManager.token == "null")
|
||||||
|
mediaManager.token = AuthManager.fetchToken(
|
||||||
|
username.value,
|
||||||
|
settingsDataStoreManager.privateKeyFlow.first()
|
||||||
|
)!!
|
||||||
|
|
||||||
|
Global.loggedIn = true
|
||||||
|
withContext(Dispatchers.IO)
|
||||||
|
{
|
||||||
|
(context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
|
||||||
|
context.abyssService?.downloader?.init()
|
||||||
|
}
|
||||||
|
}catch(e: Exception)
|
||||||
|
{
|
||||||
|
Global.loggedIn = false
|
||||||
|
withContext(Dispatchers.IO)
|
||||||
|
{
|
||||||
|
(context as AetherApp).abyssService?.downloader?.init()
|
||||||
|
}
|
||||||
|
Toast.makeText(context, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateServer(u: String, c: String, context: Context)
|
fun onUseSelfSignedCheckedChange(isChecked: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsDataStoreManager.saveUseSelfSigned(isChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateServer(u: String, c: String)
|
||||||
{
|
{
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dataStore.edit { preferences ->
|
settingsDataStoreManager.saveUrl(u)
|
||||||
preferences[URL_KEY] = u
|
settingsDataStoreManager.saveCert(c)
|
||||||
preferences[CERT_KEY] = c
|
|
||||||
}
|
|
||||||
|
|
||||||
Global.loggedIn = false
|
Global.loggedIn = false
|
||||||
|
|
||||||
val us = userNameFlow.first()
|
val us = settingsDataStoreManager.userNameFlow.first()
|
||||||
val u = urlFlow.first()
|
val p = settingsDataStoreManager.privateKeyFlow.first()
|
||||||
val c = certFlow.first()
|
|
||||||
val p = privateKeyFlow.first()
|
|
||||||
|
|
||||||
if (u == "" || c == "" || p == "" || us == "") return@launch
|
if (u == "" || p == "" || us == "") return@launch
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ApiClient.apply(u, c)
|
val usedUrl = ApiClient.apply(context, u, if(uss.first()) c else "")
|
||||||
MediaManager.token = AuthManager.fetchToken(
|
(context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
|
||||||
|
context.abyssService?.downloader?.init()
|
||||||
|
mediaManager.token = AuthManager.fetchToken(
|
||||||
us,
|
us,
|
||||||
p
|
p
|
||||||
)!!
|
)!!
|
||||||
|
|
||||||
Global.loggedIn = true
|
Global.loggedIn = true
|
||||||
Toast.makeText(context, "Server Updated", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Server Updated, Used Url: $usedUrl", Toast.LENGTH_SHORT).show()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
print(e.message)
|
print(e.message)
|
||||||
Toast.makeText(context, "Invalid Account or Server Information", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "${e.message}", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateAccount(u: String, p: String, context: Context) {
|
fun updateAccount(u: String, p: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
dataStore.edit { preferences ->
|
settingsDataStoreManager.saveUserName(u)
|
||||||
preferences[USER_NAME_KEY] = u
|
settingsDataStoreManager.savePrivateKey(p)
|
||||||
preferences[PRIVATE_KEY] = p
|
|
||||||
}
|
|
||||||
|
|
||||||
privateKey.value = "******"
|
privateKey.value = "******"
|
||||||
|
|
||||||
Global.loggedIn = false
|
Global.loggedIn = false
|
||||||
|
|
||||||
val u = userNameFlow.first()
|
val u = settingsDataStoreManager.userNameFlow.first()
|
||||||
val p = privateKeyFlow.first()
|
val p = settingsDataStoreManager.privateKeyFlow.first()
|
||||||
|
val ur = settingsDataStoreManager.urlFlow.first()
|
||||||
|
|
||||||
val ur = urlFlow.first()
|
if (u == "" || p == "" || ur == "") return@launch
|
||||||
val c = certFlow.first()
|
|
||||||
|
|
||||||
if (u == "" || p == "" || ur == "" || c == "") return@launch
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
MediaManager.token = AuthManager.fetchToken(
|
mediaManager.token = AuthManager.fetchToken(
|
||||||
u,
|
u,
|
||||||
p
|
p
|
||||||
)!!
|
)!!
|
||||||
|
|
||||||
Global.loggedIn = true
|
Global.loggedIn = true
|
||||||
|
withContext(Dispatchers.IO)
|
||||||
|
{
|
||||||
|
(context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
|
||||||
|
context.abyssService?.downloader?.init()
|
||||||
|
}
|
||||||
Toast.makeText(context, "Account Updated", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Account Updated", Toast.LENGTH_SHORT).show()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
print(e.message)
|
print(e.message)
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package com.acitelight.aether.viewModel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.acitelight.aether.model.Video
|
||||||
|
import com.acitelight.aether.model.VideoDownloadItemState
|
||||||
|
import com.acitelight.aether.service.ApiClient.createOkHttp
|
||||||
|
import com.acitelight.aether.service.FetchManager
|
||||||
|
import com.acitelight.aether.service.VideoLibrary
|
||||||
|
import com.tonyodev.fetch2.Download
|
||||||
|
import com.tonyodev.fetch2.FetchListener
|
||||||
|
import com.tonyodev.fetch2core.DownloadBlock
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class TransmissionScreenViewModel @Inject constructor(
|
||||||
|
val fetchManager: FetchManager,
|
||||||
|
@ApplicationContext val context: Context,
|
||||||
|
val videoLibrary: VideoLibrary,
|
||||||
|
) : ViewModel() {
|
||||||
|
var imageLoader: ImageLoader? = null
|
||||||
|
val downloads: SnapshotStateList<VideoDownloadItemState> = mutableStateListOf()
|
||||||
|
|
||||||
|
// map id -> state object reference (no index bookkeeping)
|
||||||
|
private val idToState: MutableMap<Int, VideoDownloadItemState> = mutableMapOf()
|
||||||
|
|
||||||
|
fun modelToVideo(model: VideoDownloadItemState): Video?
|
||||||
|
{
|
||||||
|
val fv = videoLibrary.classesMap.map { it.value }.flatten()
|
||||||
|
return fv.firstOrNull { it.klass == model.klass && it.id == model.vid }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val fetchListener = object : FetchListener {
|
||||||
|
override fun onAdded(download: Download) {
|
||||||
|
handleUpsert(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onQueued(download: Download, waitingOnNetwork: Boolean) {
|
||||||
|
handleUpsert(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWaitingNetwork(download: Download) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProgress(
|
||||||
|
download: Download,
|
||||||
|
etaInMilliSeconds: Long,
|
||||||
|
downloadedBytesPerSecond: Long
|
||||||
|
) {
|
||||||
|
handleUpsert(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPaused(download: Download) {
|
||||||
|
handleUpsert(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResumed(download: Download) {
|
||||||
|
handleUpsert(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCompleted(download: Download) {
|
||||||
|
handleUpsert(download)
|
||||||
|
|
||||||
|
if (download.extras.getString("type", "") == "main") {
|
||||||
|
val ii = videoLibrary.classesMap[download.extras.getString("class", "")]
|
||||||
|
?.indexOfFirst { it.id == download.extras.getString("id", "") }!!
|
||||||
|
|
||||||
|
val newi = videoLibrary.classesMap[download.extras.getString("class", "")]!![ii]
|
||||||
|
videoLibrary.classesMap[download.extras.getString("class", "")]!![ii] =
|
||||||
|
newi.toLocal(context.getExternalFilesDir(null)!!.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancelled(download: Download) {
|
||||||
|
handleUpsert(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRemoved(download: Download) {
|
||||||
|
handleRemove(download.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDeleted(download: Download) {
|
||||||
|
handleRemove(download.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDownloadBlockUpdated(
|
||||||
|
download: Download,
|
||||||
|
downloadBlock: DownloadBlock,
|
||||||
|
totalBlocks: Int
|
||||||
|
) {
|
||||||
|
handleUpsert(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStarted(
|
||||||
|
download: Download,
|
||||||
|
downloadBlocks: List<DownloadBlock>,
|
||||||
|
totalBlocks: Int
|
||||||
|
) {
|
||||||
|
handleUpsert(download)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(
|
||||||
|
download: Download,
|
||||||
|
error: com.tonyodev.fetch2.Error,
|
||||||
|
throwable: Throwable?
|
||||||
|
) {
|
||||||
|
handleUpsert(download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpsert(download: Download) {
|
||||||
|
viewModelScope.launch(Dispatchers.Main) {
|
||||||
|
upsertOnMain(download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleRemove(id: Int) {
|
||||||
|
viewModelScope.launch(Dispatchers.Main) {
|
||||||
|
removeOnMain(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upsertOnMain(download: Download) {
|
||||||
|
val existing = idToState[download.id]
|
||||||
|
if (existing != null) {
|
||||||
|
// update fields in-place -> minimal recomposition
|
||||||
|
existing.filePath = download.file
|
||||||
|
existing.fileName = download.request.extras.getString("name", "")
|
||||||
|
existing.url = download.url
|
||||||
|
existing.progress = download.progress
|
||||||
|
existing.status = download.status
|
||||||
|
existing.downloadedBytes = download.downloaded
|
||||||
|
existing.totalBytes = download.total
|
||||||
|
} else {
|
||||||
|
// new item: add to head (or tail depending on preference)
|
||||||
|
val newState = downloadToState(download)
|
||||||
|
downloads.add(0, newState)
|
||||||
|
idToState[newState.id] = newState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeOnMain(id: Int) {
|
||||||
|
val state = idToState.remove(id)
|
||||||
|
if (state != null) {
|
||||||
|
downloads.remove(state)
|
||||||
|
} else {
|
||||||
|
val idx = downloads.indexOfFirst { it.id == id }
|
||||||
|
if (idx >= 0) {
|
||||||
|
val removed = downloads.removeAt(idx)
|
||||||
|
idToState.remove(removed.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadToState(download: Download): VideoDownloadItemState {
|
||||||
|
val filePath = download.file
|
||||||
|
|
||||||
|
return VideoDownloadItemState(
|
||||||
|
id = download.id,
|
||||||
|
fileName = download.request.extras.getString("name", ""),
|
||||||
|
filePath = filePath,
|
||||||
|
url = download.url,
|
||||||
|
progress = download.progress,
|
||||||
|
status = download.status,
|
||||||
|
downloadedBytes = download.downloaded,
|
||||||
|
totalBytes = download.total,
|
||||||
|
klass = download.extras.getString("class", ""),
|
||||||
|
vid = download.extras.getString("id", ""),
|
||||||
|
type = download.extras.getString("type", "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// UI actions delegated to FetchManager
|
||||||
|
fun pause(id: Int) = fetchManager.pause(id)
|
||||||
|
fun resume(id: Int) = fetchManager.resume(id)
|
||||||
|
fun cancel(id: Int) = fetchManager.cancel(id)
|
||||||
|
fun delete(id: Int, deleteFile: Boolean = true) {
|
||||||
|
fetchManager.delete(id) {
|
||||||
|
viewModelScope.launch(Dispatchers.Main) { removeOnMain(id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
fetchManager.removeListener()
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
imageLoader = ImageLoader.Builder(context)
|
||||||
|
.components {
|
||||||
|
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
fetchManager.setListener(fetchListener)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
fetchManager.getAllDownloads { list ->
|
||||||
|
downloads.clear()
|
||||||
|
idToState.clear()
|
||||||
|
list.sortedBy { it.extras.getString("name", "") }.forEach { d ->
|
||||||
|
val s = downloadToState(d)
|
||||||
|
downloads.add(s)
|
||||||
|
idToState[s.id] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +1,65 @@
|
|||||||
package com.acitelight.aether.viewModel
|
package com.acitelight.aether.viewModel
|
||||||
|
|
||||||
import android.app.Activity
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.State
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableLongStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
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.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.Player.STATE_READY
|
import androidx.media3.common.Player.STATE_READY
|
||||||
|
import androidx.media3.common.text.Cue
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
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 coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import com.acitelight.aether.Global
|
|
||||||
import com.acitelight.aether.model.Video
|
import com.acitelight.aether.model.Video
|
||||||
import com.acitelight.aether.model.VideoQueryIndex
|
import com.acitelight.aether.model.VideoQueryIndex
|
||||||
|
import com.acitelight.aether.model.VideoRecord
|
||||||
|
import com.acitelight.aether.model.VideoRecordDatabase
|
||||||
import com.acitelight.aether.service.ApiClient.createOkHttp
|
import com.acitelight.aether.service.ApiClient.createOkHttp
|
||||||
import com.acitelight.aether.service.MediaManager
|
import com.acitelight.aether.service.MediaManager
|
||||||
import com.acitelight.aether.service.RecentManager
|
import com.acitelight.aether.service.RecentManager
|
||||||
|
import com.acitelight.aether.view.formatTime
|
||||||
import com.acitelight.aether.view.hexToString
|
import com.acitelight.aether.view.hexToString
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.media3.common.Tracks
|
||||||
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
|
import com.acitelight.aether.Global
|
||||||
|
import com.acitelight.aether.model.KeyImage
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
class VideoPlayerViewModel() : ViewModel()
|
@HiltViewModel
|
||||||
{
|
class VideoPlayerViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
val mediaManager: MediaManager,
|
||||||
|
val recentManager: RecentManager
|
||||||
|
) : ViewModel() {
|
||||||
var tabIndex by mutableIntStateOf(0)
|
var tabIndex by mutableIntStateOf(0)
|
||||||
var isPlaying by mutableStateOf(true)
|
var isPlaying by mutableStateOf(true)
|
||||||
var playProcess by mutableFloatStateOf(0.0f)
|
var playProcess by mutableFloatStateOf(0.0f)
|
||||||
var planeVisibility by mutableStateOf(true)
|
var planeVisibility by mutableStateOf(true)
|
||||||
var isLongPressing by mutableStateOf(false)
|
var isLongPressing by mutableStateOf(false)
|
||||||
@@ -47,84 +69,267 @@ class VideoPlayerViewModel() : ViewModel()
|
|||||||
// 1 : Volume
|
// 1 : Volume
|
||||||
// 2 : Brightness
|
// 2 : Brightness
|
||||||
var draggingPurpose by mutableIntStateOf(-1)
|
var draggingPurpose by mutableIntStateOf(-1)
|
||||||
|
var locked by mutableStateOf(false)
|
||||||
var thumbUp by mutableIntStateOf(0)
|
private var _init: Boolean = false
|
||||||
var thumbDown by mutableIntStateOf(0)
|
|
||||||
var star by mutableStateOf(false)
|
|
||||||
|
|
||||||
private var _init: Boolean = false;
|
|
||||||
var startPlaying by mutableStateOf(false)
|
var startPlaying by mutableStateOf(false)
|
||||||
var renderedFirst = false
|
var renderedFirst = false
|
||||||
var video: Video? = null
|
var videos: List<Video> = listOf()
|
||||||
|
|
||||||
val dataSourceFactory = OkHttpDataSource.Factory(createOkHttp())
|
private val httpDataSourceFactory = OkHttpDataSource.Factory(createOkHttp())
|
||||||
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)
|
||||||
|
var cues by mutableStateOf(listOf<Cue>())
|
||||||
|
|
||||||
|
var currentKlass = mutableStateOf("")
|
||||||
|
var currentId = mutableStateOf("")
|
||||||
|
var currentName = mutableStateOf("")
|
||||||
|
var currentDuration = mutableLongStateOf(0)
|
||||||
|
var currentGallery = mutableStateOf(listOf<KeyImage>())
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
@Composable
|
fun init(videoId: String) {
|
||||||
fun Init(videoId: String)
|
if (_init)
|
||||||
{
|
return
|
||||||
if(_init) return;
|
_init = true
|
||||||
val context = LocalContext.current
|
|
||||||
val v = videoId.hexToString()
|
|
||||||
|
|
||||||
imageLoader = ImageLoader.Builder(context)
|
val vs = videoId.hexToString().split(",").map { it.split("/") }.toMutableList()
|
||||||
|
imageLoader = ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
remember {
|
viewModelScope.launch {
|
||||||
viewModelScope.launch {
|
val ii = database.userDao().getAll().first()
|
||||||
video = MediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!!
|
val ix = ii.filter { it.id in videos.map{ m -> m.id } }.maxByOrNull { it.time }
|
||||||
RecentManager.Push(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1]))
|
|
||||||
_player = ExoPlayer
|
|
||||||
.Builder(context)
|
|
||||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
|
|
||||||
.build().apply {
|
|
||||||
val url = video?.getVideo() ?: ""
|
|
||||||
val mediaItem = MediaItem.fromUri(url)
|
|
||||||
setMediaItem(mediaItem)
|
|
||||||
prepare()
|
|
||||||
playWhenReady = true
|
|
||||||
|
|
||||||
addListener(object : Player.Listener {
|
videos = mediaManager.queryVideoBulk(vs.first()[0], vs.map { it[1] })!!
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
||||||
if (playbackState == STATE_READY) {
|
startPlay(
|
||||||
startPlaying = true
|
if (ix != null)
|
||||||
}
|
videos.first { it.id == ix.id }
|
||||||
|
else videos.first()
|
||||||
|
)
|
||||||
|
startListen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to resolve the given subtitle pathOrUrl to a Uri.
|
||||||
|
* - If it's a local path and file exists -> Uri.fromFile
|
||||||
|
* - If it's a http(s) URL -> try HEAD; if HEAD unsupported, try GET with Range: bytes=0-1
|
||||||
|
* - Return null when unreachable / 404 / not exist
|
||||||
|
*/
|
||||||
|
private suspend fun tryResolveSubtitleUri(pathOrUrl: String?): Uri? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (pathOrUrl.isNullOrBlank()) return@withContext null
|
||||||
|
val trimmed = pathOrUrl.trim()
|
||||||
|
|
||||||
|
// Remote URL case (http/https)
|
||||||
|
if (trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith(
|
||||||
|
"https://",
|
||||||
|
ignoreCase = true
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val client = createOkHttp()
|
||||||
|
|
||||||
|
val headReq = Request.Builder().url(trimmed).head().build()
|
||||||
|
val headResp = try {
|
||||||
|
client.newCall(headReq).execute()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
headResp?.use { resp ->
|
||||||
|
val code = resp.code
|
||||||
|
if (code == 200 || code == 206) {
|
||||||
|
return@withContext trimmed.toUri()
|
||||||
|
}
|
||||||
|
if (code == 404) {
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val rangeReq = Request.Builder()
|
||||||
|
.url(trimmed)
|
||||||
|
.addHeader("Range", "bytes=0-1")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val rangeResp = try {
|
||||||
|
client.newCall(rangeReq).execute()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
rangeResp?.use { resp ->
|
||||||
|
val code = resp.code
|
||||||
|
if (code == 206) {
|
||||||
|
return@withContext trimmed.toUri()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRenderedFirstFrame() {
|
if (code == 200) {
|
||||||
super.onRenderedFirstFrame()
|
return@withContext trimmed.toUri()
|
||||||
renderedFirst = true
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
if (code == 404) {
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return@withContext null
|
||||||
}
|
}
|
||||||
startListen()
|
return@withContext null
|
||||||
|
} else {
|
||||||
|
// Local path
|
||||||
|
val f = File(trimmed)
|
||||||
|
return@withContext if (f.exists() && f.isFile) Uri.fromFile(f) else null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_init = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
fun startListen()
|
fun startListen() {
|
||||||
{
|
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
while (_player?.isReleased != true) {
|
while (_init) {
|
||||||
val __player = _player!!;
|
player?.let { playProcess = it.currentPosition.toFloat() / it.duration.toFloat() }
|
||||||
playProcess = __player.currentPosition.toFloat() / __player.duration.toFloat()
|
|
||||||
delay(100)
|
delay(100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var _player: ExoPlayer? = null;
|
@OptIn(UnstableApi::class)
|
||||||
|
suspend fun startPlay(video: Video) {
|
||||||
|
if (currentId.value.isNotEmpty() && currentKlass.value.isNotEmpty()) {
|
||||||
|
val pos = player?.currentPosition ?: 0L
|
||||||
|
database.userDao().insert(
|
||||||
|
VideoRecord(
|
||||||
|
currentId.value,
|
||||||
|
currentKlass.value,
|
||||||
|
pos,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
videos.joinToString(",") { it.id })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedFirst = false
|
||||||
|
currentId.value = video.id
|
||||||
|
currentKlass.value = video.klass
|
||||||
|
currentName.value = video.video.name
|
||||||
|
currentDuration.longValue = video.video.duration
|
||||||
|
currentGallery.value = video.getGallery()
|
||||||
|
|
||||||
|
player?.apply {
|
||||||
|
stop()
|
||||||
|
clearMediaItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
recentManager.pushVideo(context, VideoQueryIndex(video.klass, video.id))
|
||||||
|
|
||||||
|
val subtitleCandidate = video.getSubtitle().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) {
|
||||||
|
if (playbackState == STATE_READY) {
|
||||||
|
startPlaying = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
val videoUri = if (video.isLocal) Uri.fromFile(File(url)) else url.toUri()
|
||||||
|
|
||||||
|
val mediaItem: MediaItem = if (subtitleUri != null) {
|
||||||
|
val subConfig = MediaItem.SubtitleConfiguration.Builder(subtitleUri)
|
||||||
|
.setMimeType("text/vtt")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
MediaItem.Builder()
|
||||||
|
.setUri(videoUri)
|
||||||
|
.setSubtitleConfigurations(listOf(subConfig))
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
MediaItem.fromUri(videoUri)
|
||||||
|
}
|
||||||
|
|
||||||
|
player?.apply {
|
||||||
|
setMediaItem(mediaItem)
|
||||||
|
prepare()
|
||||||
|
playWhenReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var player: ExoPlayer? = null
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
_player?.release()
|
|
||||||
|
_init = false
|
||||||
|
val pos = player?.currentPosition ?: 0L
|
||||||
|
player?.release()
|
||||||
|
player = null
|
||||||
|
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
if (currentId.value.isNotEmpty() && currentKlass.value.isNotEmpty())
|
||||||
|
database.userDao().insert(
|
||||||
|
VideoRecord(
|
||||||
|
currentId.value,
|
||||||
|
currentKlass.value,
|
||||||
|
pos,
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
videos.joinToString(",") { it.id })
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,128 @@
|
|||||||
package com.acitelight.aether.viewModel
|
package com.acitelight.aether.viewModel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
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 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.dataStore
|
|
||||||
import com.acitelight.aether.model.Video
|
import com.acitelight.aether.model.Video
|
||||||
import com.acitelight.aether.service.ApiClient
|
|
||||||
import com.acitelight.aether.service.ApiClient.createOkHttp
|
import com.acitelight.aether.service.ApiClient.createOkHttp
|
||||||
import com.acitelight.aether.service.AuthManager
|
import com.acitelight.aether.service.FetchManager
|
||||||
import com.acitelight.aether.service.MediaManager
|
import com.acitelight.aether.service.MediaManager
|
||||||
import kotlinx.coroutines.flow.Flow
|
import com.acitelight.aether.service.RecentManager
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import com.acitelight.aether.service.VideoLibrary
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import com.tonyodev.fetch2.Status
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
class VideoScreenViewModel(application: Application) : AndroidViewModel(application)
|
|
||||||
{
|
|
||||||
private val dataStore = application.dataStore
|
|
||||||
private val USER_NAME_KEY = stringPreferencesKey("user_name")
|
|
||||||
private val PRIVATE_KEY = stringPreferencesKey("private_key")
|
|
||||||
|
|
||||||
val userNameFlow: Flow<String> = dataStore.data.map { preferences ->
|
|
||||||
preferences[USER_NAME_KEY] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
val privateKeyFlow: Flow<String> = dataStore.data.map { preferences ->
|
|
||||||
preferences[PRIVATE_KEY] ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class VideoScreenViewModel @Inject constructor(
|
||||||
|
private val fetchManager: FetchManager,
|
||||||
|
@ApplicationContext val context: Context,
|
||||||
|
val mediaManager: MediaManager,
|
||||||
|
val recentManager: RecentManager,
|
||||||
|
val videoLibrary: VideoLibrary
|
||||||
|
) : ViewModel() {
|
||||||
private val _tabIndex = mutableIntStateOf(0)
|
private val _tabIndex = mutableIntStateOf(0)
|
||||||
val tabIndex: State<Int> = _tabIndex
|
val tabIndex: State<Int> = _tabIndex
|
||||||
|
|
||||||
val videos = mutableStateListOf<Video>()
|
|
||||||
private val _klasses = MutableStateFlow<List<String>>(emptyList())
|
|
||||||
val klasses: StateFlow<List<String>> = _klasses;
|
|
||||||
var imageLoader: ImageLoader? = null;
|
var imageLoader: ImageLoader? = null;
|
||||||
|
var menuVisibility = mutableStateOf(false)
|
||||||
|
var searchFilter = mutableStateOf("")
|
||||||
|
var doneInit = mutableStateOf(false)
|
||||||
|
|
||||||
@Composable
|
suspend fun init() {
|
||||||
fun SetupClient()
|
fetchManager.configured.filter { it }.first()
|
||||||
{
|
|
||||||
val context = LocalContext.current
|
if (Global.loggedIn) {
|
||||||
imageLoader = ImageLoader.Builder(context)
|
videoLibrary.classes.addAll(mediaManager.listVideoKlasses())
|
||||||
|
if(videoLibrary.classes.isEmpty())
|
||||||
|
return
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
for (it in videoLibrary.classes) {
|
||||||
|
videoLibrary.updatingMap[i++] = false
|
||||||
|
videoLibrary.classesMap[it] = mutableStateListOf<Video>()
|
||||||
|
}
|
||||||
|
videoLibrary.updatingMap[0] = true
|
||||||
|
val vl =
|
||||||
|
mediaManager.queryVideoBulk(videoLibrary.classes[0], mediaManager.queryVideoKlasses(videoLibrary.classes[0]))
|
||||||
|
|
||||||
|
if (vl != null) {
|
||||||
|
val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name })
|
||||||
|
videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
videoLibrary.classes.add("Offline")
|
||||||
|
videoLibrary.updatingMap[0] = true
|
||||||
|
videoLibrary.classesMap["Offline"] = mutableStateListOf<Video>()
|
||||||
|
|
||||||
|
val downloaded = fetchManager.getAllDownloadsAsync().filter {
|
||||||
|
it.status == Status.COMPLETED && it.extras.getString("isComic", "") != "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsonQuery = downloaded.map{ File(
|
||||||
|
context.getExternalFilesDir(null),
|
||||||
|
"videos/${it.extras.getString("class", "")}/${it.extras.getString("id", "")}/summary.json").readText() }
|
||||||
|
.map { Json.decodeFromString<Video>(it).toLocal(context.getExternalFilesDir(null)!!.path) }
|
||||||
|
|
||||||
|
videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(jsonQuery)
|
||||||
|
}
|
||||||
|
|
||||||
|
doneInit.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTabIndex(index: Int) {
|
||||||
|
viewModelScope.launch()
|
||||||
|
{
|
||||||
|
_tabIndex.intValue = index;
|
||||||
|
if (videoLibrary.updatingMap[index] == true) return@launch
|
||||||
|
|
||||||
|
videoLibrary.updatingMap[index] = true
|
||||||
|
|
||||||
|
val vl = mediaManager.queryVideoBulk(
|
||||||
|
videoLibrary.classes[index],
|
||||||
|
mediaManager.queryVideoKlasses(videoLibrary.classes[index])
|
||||||
|
)
|
||||||
|
|
||||||
|
if (vl != null) {
|
||||||
|
val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name })
|
||||||
|
videoLibrary.classesMap[videoLibrary.classes[index]]?.addAll(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun download(video: Video) {
|
||||||
|
fetchManager.startVideoDownload(video)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
imageLoader = ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
add(OkHttpNetworkFetcherFactory(createOkHttp()))
|
||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun init() {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
_klasses.value = MediaManager.listVideoKlasses()
|
|
||||||
|
|
||||||
MediaManager.listVideos(_klasses.value.first()){
|
|
||||||
v -> if(0 == tabIndex.value && !videos.contains(v)) videos.add(videos.size, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setTabIndex(index: Int)
|
|
||||||
{
|
|
||||||
viewModelScope.launch()
|
|
||||||
{
|
|
||||||
_tabIndex.intValue = index;
|
|
||||||
|
|
||||||
videos.clear()
|
|
||||||
MediaManager.listVideos(_klasses.value[index])
|
|
||||||
{
|
|
||||||
v -> if(index == tabIndex.value) videos.add(videos.size, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
init()
|
init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
74
app/src/main/res/drawable/aether_background.xml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector
|
||||||
|
android:height="108dp"
|
||||||
|
android:width="108dp"
|
||||||
|
android:viewportHeight="108"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
|
||||||
|
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/aether.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/aether_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/aether_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/aether_round.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/aether_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/aether_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/aether.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/src/main/res/mipmap-hdpi/aether_foreground.webp
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
app/src/main/res/mipmap-hdpi/aether_round.webp
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
app/src/main/res/mipmap-mdpi/aether.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/aether_foreground.webp
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
app/src/main/res/mipmap-mdpi/aether_round.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
app/src/main/res/mipmap-xhdpi/aether.webp
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
app/src/main/res/mipmap-xhdpi/aether_foreground.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
app/src/main/res/mipmap-xhdpi/aether_round.webp
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/aether.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/aether_foreground.webp
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/aether_round.webp
Normal file
|
After Width: | Height: | Size: 9.9 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/aether.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/aether_foreground.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/aether_round.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -3,4 +3,7 @@ plugins {
|
|||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
|
||||||
|
alias(libs.plugins.hilt.android) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.12.1"
|
agp = "8.13.0"
|
||||||
|
ariaCompiler = "latest"
|
||||||
bcprovJdk15on = "1.70"
|
bcprovJdk15on = "1.70"
|
||||||
bcprovJdk18on = "1.81"
|
bcprovJdk18on = "1.81"
|
||||||
coilCompose = "3.3.0"
|
coilCompose = "3.3.0"
|
||||||
@@ -7,24 +8,35 @@ coilNetworkOkhttp = "3.3.0"
|
|||||||
converterGson = "3.0.0"
|
converterGson = "3.0.0"
|
||||||
datastorePreferences = "1.1.7"
|
datastorePreferences = "1.1.7"
|
||||||
exoplayerplus = "0.2.0"
|
exoplayerplus = "0.2.0"
|
||||||
|
fetch2 = "3.4.1"
|
||||||
|
fetch2okhttp = "3.4.1"
|
||||||
gson = "2.13.1"
|
gson = "2.13.1"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.20"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
espressoCore = "3.7.0"
|
espressoCore = "3.7.0"
|
||||||
kotlinxSerializationJson = "1.9.0"
|
kotlinxSerializationJson = "1.9.0"
|
||||||
lifecycleRuntimeKtx = "2.9.2"
|
lifecycleRuntimeKtx = "2.9.3"
|
||||||
activityCompose = "1.10.1"
|
activityCompose = "1.11.0"
|
||||||
composeBom = "2025.08.00"
|
composeBom = "2025.09.00"
|
||||||
media3Common = "1.8.0"
|
media3Common = "1.8.0"
|
||||||
media3Exoplayer = "1.8.0"
|
media3Exoplayer = "1.8.0"
|
||||||
|
media3ExoplayerFfmpeg = "1.8.0"
|
||||||
media3Ui = "1.8.0"
|
media3Ui = "1.8.0"
|
||||||
navigationCompose = "2.9.3"
|
navigationCompose = "2.9.4"
|
||||||
okhttp = "5.1.0"
|
okhttp = "5.1.0"
|
||||||
retrofit = "3.0.0"
|
retrofit = "3.0.0"
|
||||||
retrofit2KotlinxSerializationConverter = "1.0.0"
|
retrofit2KotlinxSerializationConverter = "1.0.0"
|
||||||
media3DatasourceOkhttp = "1.8.0"
|
media3DatasourceOkhttp = "1.8.0"
|
||||||
|
roomCompiler = "2.8.0"
|
||||||
|
roomKtx = "2.8.0"
|
||||||
|
roomRuntime = "2.8.0"
|
||||||
|
|
||||||
|
ksp = "2.1.21-2.0.2"
|
||||||
|
hilt = "2.57.1"
|
||||||
|
hilt-navigation-compose = "1.3.0"
|
||||||
|
composeMaterialCore = "1.5.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -34,10 +46,16 @@ androidx-media3-common = { module = "androidx.media3:media3-common", version.ref
|
|||||||
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }
|
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }
|
||||||
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" }
|
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" }
|
||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomCompiler" }
|
||||||
|
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" }
|
||||||
|
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" }
|
||||||
|
aria-compiler = { module = "com.arialyy.aria:aria-compiler", version.ref = "ariaCompiler" }
|
||||||
bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bcprovJdk15on" }
|
bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bcprovJdk15on" }
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
|
||||||
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" }
|
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" }
|
||||||
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" }
|
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" }
|
||||||
|
fetch2 = { module = "com.github.tonyofrancis.Fetch:fetch2", version.ref = "fetch2" }
|
||||||
|
fetch2okhttp = { module = "com.github.tonyofrancis.Fetch:fetch2okhttp", version.ref = "fetch2okhttp" }
|
||||||
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
@@ -58,8 +76,15 @@ retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit
|
|||||||
retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" }
|
retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" }
|
||||||
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" }
|
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" }
|
||||||
|
|
||||||
|
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||||
|
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
|
||||||
|
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
|
||||||
|
androidx-compose-material-core = { group = "androidx.wear.compose", name = "compose-material-core", version.ref = "composeMaterialCore" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
@@ -16,6 +16,9 @@ dependencyResolutionManagement {
|
|||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven {
|
||||||
|
url = uri("https://jitpack.io")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||