76 Commits

Author SHA1 Message Date
rootacite
9efbcdfe8a [feat] Optional sort, tags folder 2025-10-29 23:53:14 +08:00
rootacite
c3e0a23ed1 [update] Comic sort policy 2025-10-29 20:14:07 +08:00
acite
7be18dd517 [feat] Playlist remember 2025-10-09 11:33:34 +08:00
acite
a13ddbdd87 [feat] Live framework. 2025-10-06 22:54:08 +08:00
acite
390094b8b0 [fix&optimize] Fix the issue of element teleportation in ComicScreen pages 2025-10-02 12:24:26 +08:00
acite
b360724dca [fix] Chapter progress 2025-10-02 01:34:09 +08:00
acite
db8d5ef4d5 [fix] Cover height meansure 2025-10-02 01:20:13 +08:00
acite
2c4d5d2366 [fix] Too big cover 2025-10-01 23:04:16 +08:00
acite
200cf33e5a [feat] Adaptive coverHeight 2025-10-01 19:56:42 +08:00
acite
603c2c38aa [feat] UI optimization 3 2025-10-01 19:47:00 +08:00
acite
7c99ea394b [feat] Player Logic 2025-10-01 02:37:40 +08:00
acite
614a0d591d [fix] Playlist load logic 2025-10-01 02:07:19 +08:00
acite
24dda0eb2c [feat] Better UI 2 2025-10-01 02:04:37 +08:00
acite
c5a5826321 [fix] Fix the issue where the delete task button is invalid 2025-10-01 00:48:14 +08:00
acite
ba4811e65f [doc] Doc Update 2025-09-29 20:49:44 +08:00
acite
8b5adfd6b7 [feat] Better Transmission UI 2025-09-29 20:34:56 +08:00
acite
02d8d30da7 [fix] Download Logic 2025-09-29 04:02:15 +08:00
acite
422da51a74 [update] Remove token from Query param, move to cookies 2025-09-29 01:19:12 +08:00
acite
393419afd7 [optimize] Refactoring API client injection architecture 2025-09-28 14:31:03 +08:00
acite
88392444a4 [feat] Task Statistics 2025-09-28 02:03:20 +08:00
acite
2166229923 [fix] Offline mode display exception 2025-09-28 01:54:02 +08:00
acite
9bad0dcbc2 [fix] Landscape Playlist 2025-09-27 21:07:28 +08:00
acite
c21defb426 [fix] careless mistake 2025-09-27 19:22:52 +08:00
acite
e6b69ef14a [update] Fullscreen mode 2025-09-27 19:04:27 +08:00
acite
dcef25a526 [update] Reduce the protected area 2025-09-27 17:35:38 +08:00
acite
cf0c68812d [update] Set gesture protection for the bottom of the screen 2025-09-27 17:31:22 +08:00
acite
22469e1d49 [fix] Use before register 2025-09-27 16:06:02 +08:00
acite
ba1a7c9a92 [feat] Fix local detection logic defects 2025-09-27 15:59:39 +08:00
acite
584fc1f785 [feat] Smart playlist status sync 2025-09-27 15:14:51 +08:00
acite
8184ab211c [fix] Fix group redundant videos and jump logic 2025-09-27 15:07:55 +08:00
acite
4e346a83ee [fix&feat] Fix local judgment logic to prevent partial downloaded projects from being judged as local, Better playlists 2025-09-27 14:48:41 +08:00
acite
5b770a965d [feat&optimize] Group Batch Download, Optimize download logic 2025-09-27 00:37:57 +08:00
acite
a89f892306 [feat&optimize] Video grouping recording, large-scale reconstruction 2025-09-26 12:48:34 +08:00
acite
e38d77b2f6 [feat] Video Group 2025-09-26 03:09:14 +08:00
acite
756c2ea9f8 [update] Landspace player top bar padding 2025-09-23 13:25:24 +08:00
acite
c9c3306766 [update] New full screen switching mechanism 2025-09-23 13:21:51 +08:00
acite
49751c55d9 [feat] Video Player lock 2025-09-23 02:53:42 +08:00
acite
d918508c16 [fix] Video Remember id mass 2025-09-22 13:15:16 +08:00
acite
d858cd18bd [update] Video Card Font 2025-09-21 01:34:01 +08:00
acite
82f537038c [feat] Show comic id 2025-09-20 14:28:05 +08:00
acite
a298cb75e2 [fix] Crash when page initialized before view model 2025-09-20 14:16:46 +08:00
acite
92f0e8543e [fix] Crash while empty class set 2025-09-20 13:38:46 +08:00
acite
f78bcc83c9 [fix] Vtt ext name 2025-09-20 13:11:24 +08:00
acite
55ea2e1ae3 [feat] Video system optimization2 2025-09-20 03:18:25 +08:00
acite
947ffc4599 [fix] Too long class 2025-09-19 18:53:49 +08:00
acite
1b24312a95 [feat] Video position remember& New Icon and Theme 2025-09-18 23:44:07 +08:00
acite
a15325deeb [optimize] UI optimizition 2025-09-18 00:11:25 +08:00
acite
c402e18206 [fix] Can't hot reload server config in abyss mode. 2025-09-17 00:09:30 +08:00
acite
2260f26d9a [feat] New video UI& Basic Search feature 2025-09-16 18:23:45 +08:00
acite
829804abee [feat] Image Recents 2025-09-16 03:38:04 +08:00
acite
e94249aa8f [feat] Complete video caching system 2025-09-15 03:15:43 +08:00
acite
ad51c5da2f [fix] Fix video card appearance 2025-09-15 00:18:01 +08:00
acite
e6788d801a [doc] Icon& Doc 2025-09-14 23:22:24 +08:00
acite
54c9d326c6 [optimize] Architecture is shifting towards Hilt comprehensively 2025-09-14 20:59:51 +08:00
acite
f7701cc85b [update] New Icon& UI Theme 2025-09-14 19:59:28 +08:00
acite
cc540903d3 [update] Better UI 2025-09-14 18:26:05 +08:00
acite
9c04d7679c [optimize] Inceased UI Response 2025-09-14 12:35:38 +08:00
acite
c330a1e70c [optimize] merge network write 2025-09-14 01:02:39 +08:00
acite
ffa70d9d34 [feat] Abyss Protocol authentication 2025-09-13 17:02:12 +08:00
acite
7d07f19440 [feat] Bulk Requests 2025-09-13 14:51:09 +08:00
acite
b4e73c4212 [feix] Ip address fix 2025-09-13 03:17:32 +08:00
acite
d28804178e [feat] Abyss 'HELLY' encryption protocol 😈 2025-09-13 03:15:08 +08:00
acite
10f316cb48 [feat] Optional self signed certificate 2025-09-10 23:51:13 +08:00
acite
b48f8ce6b0 [feat] Dynamic certificate authentication 2025-09-10 14:51:07 +08:00
acite
f6583ffcf1 [feat] Smart Server Selection 2025-09-07 23:04:15 +08:00
acite
aacd226260 [feat] Comic Tags 2025-09-07 13:09:25 +08:00
acite
514e99d7db [feat] Comic Bookmark 2025-09-05 12:57:50 +08:00
acite
18d021a8e5 [feat] Comic Resume 2025-09-02 19:08:11 +08:00
acite
daa66a9ecc [feat] Comic Reader 2025-09-01 20:41:28 +08:00
acite
ea574895ab [fix] Recent list 2025-08-28 02:34:24 +08:00
acite
06ada999c3 [fix] Repeatedly adding video cards when switching lists 2025-08-28 01:09:04 +08:00
acite
e249ae27c9 [feat] List natural sorting 2025-08-28 00:49:59 +08:00
acite
1a301770e2 [merge] Merge branch 'dev-feat2' 2025-08-27 03:57:48 +08:00
acite
0067f3000b [merge] Merge branch 'dev-optimize2' 2025-08-27 03:57:02 +08:00
acite
b5940aecc3 [feat] Beautify the interface, title bar in full screen mode 2025-08-27 03:56:26 +08:00
acite
76054da910 [feat] Gestures for volume and brightness control 2025-08-27 03:43:52 +08:00
93 changed files with 8099 additions and 1766 deletions

View File

@@ -6,11 +6,33 @@
_🚀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/> ## 🎯 Target
<br/>
<br/> The ultimate goal of this software project is to enable anyone to easily build a smooth media library that they can fully manage and control,
contribute to with trusted individuals, and securely access from any location without worrying about unauthorized use of their data by third parties.
Undoubtedly, this is a distant goal, but in any case,
I hope this project can make a modest contribution to the advancement of cybersecurity and the protection of user privacy.
## Key Features
- **Media Management**: Organize and serve images, videos, and live streams with structured directory support.
- **User Authentication**: Challenge-response authentication using **Ed25519** signatures. No private keys are ever transmitted.
- **Role-Based Access Control (RBAC)**: Hierarchical user privileges with configurable permissions for resources.
- **Secure Proxy**: Built-in HTTP/S proxy with end-to-end encrypted tunneling using **X25519** key exchange and **ChaCha20-Poly1305** encryption.
- **Resource-Level Permissions**: Fine-grained control over files and directories using a SQLite-based attribute system.
- **Task System**: Support for background tasks such as media uploads and processing with chunk-based integrity validation.
- **RESTful API**: Fully documented API endpoints for media access, user management, and task control.
## Technology Stack
- **Backend**: ASP.NET Core 9, MVC, Dependency Injection
- **Database**: SQLite with async ORM support
- **Cryptography**: NSec.Cryptography (Ed25519, X25519), ChaCha20Poly1305
- **Media Handling**: Range requests, MIME type detection, chunked uploads
- **Security**: Rate limiting, IP binding, token expiration, secure headers
## Development background ## Development background
@@ -26,15 +48,15 @@ _🚀This is the client of the multimedia server Abyss, which can also be extend
- [x] Hide private key after user input - [x] 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

BIN
aether_clip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

View File

@@ -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,27 @@ android {
} }
dependencies { dependencies {
implementation(libs.persistentcookiejar)
implementation(libs.fetch2)
implementation(libs.fetch2okhttp)
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
implementation(libs.androidx.compose.material.core)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.compose.animation)
ksp(libs.hilt.android.compiler)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.datastore.preferences) implementation(libs.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)

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View 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()
}
}
}

View File

@@ -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)
} }
} }

View File

@@ -8,4 +8,18 @@ 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
var isFullScreen by mutableStateOf(false)
fun updateRelate(v: List<Video>, s: Video)
{
sameClassVideos = if (v.contains(s)) {
val index = v.indexOf(s)
val afterS = v.subList(index, v.size)
val beforeS = v.subList(0, index)
afterS + beforeS
} else {
v
}
}
} }

View File

@@ -1,43 +1,48 @@
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.net.http.SslCertificate.saveState
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.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.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
import androidx.compose.material3.SegmentedButtonDefaults.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType import androidx.navigation.NavType
@@ -47,15 +52,43 @@ 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.ComicScreen import com.acitelight.aether.view.pages.ComicGridView
import com.acitelight.aether.view.HomeScreen import com.acitelight.aether.view.pages.ComicPageView
import com.acitelight.aether.view.MeScreen import com.acitelight.aether.view.pages.ComicScreen
import com.acitelight.aether.view.VideoPlayer import com.acitelight.aether.view.pages.HomeScreen
import com.acitelight.aether.view.VideoScreen import com.acitelight.aether.view.pages.LiveScreen
import com.acitelight.aether.view.pages.MeScreen
import com.acitelight.aether.view.pages.TransmissionScreen
import com.acitelight.aether.view.pages.VideoPlayer
import com.acitelight.aether.view.pages.VideoScreen
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val app = application as AetherApp
lifecycleScope.launch {
app.isServiceInitialized?.filter { it }?.first()
val intent = Intent(this@MainActivity, MainScreenActivity::class.java)
startActivity(intent)
finish()
}
}
}
@AndroidEntryPoint
class MainScreenActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.attributes = window.attributes.apply {
screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
}
enableEdgeToEdge() enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
@@ -67,22 +100,17 @@ class MainActivity : ComponentActivity() {
} }
@Composable fun setFullScreen(view: View, isFullScreen: Boolean) {
fun ToggleFullScreen(isFullScreen: Boolean) Global.isFullScreen = isFullScreen
{ val window = (view.context as Activity).window
val view = LocalView.current val insetsController = WindowCompat.getInsetsController(window, view)
LaunchedEffect(isFullScreen) { if (isFullScreen) {
val window = (view.context as Activity).window insetsController.hide(WindowInsetsCompat.Type.systemBars())
val insetsController = WindowCompat.getInsetsController(window, view) insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
if (isFullScreen) { } else {
insetsController.hide(WindowInsetsCompat.Type.systemBars()) insetsController.show(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
insetsController.show(WindowInsetsCompat.Type.systemBars())
}
} }
} }
@@ -94,6 +122,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
@@ -106,34 +136,73 @@ fun AppNavigation() {
) { ) {
BottomNavigationBar(navController = navController) BottomNavigationBar(navController = navController)
} }
if(shouldShowBottomBar)
ToggleFullScreen(false)
} }
) { 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(!Global.isFullScreen) Modifier.padding(innerPadding) else Modifier.padding(0.dp)
) { ) {
composable(Screen.Home.route) { composable(
HomeScreen(navController = navController) Screen.Home.route,
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }
) {
CardPage(title = "Home") {
HomeScreen(navController = navController)
}
} }
composable(Screen.Video.route) { composable(Screen.Video.route,
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) {
VideoScreen(navController = navController) VideoScreen(navController = navController)
} }
composable(Screen.Comic.route) { composable(Screen.Comic.route,
ComicScreen() enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) {
CardPage(title = "Comic") {
ComicScreen(navController = navController)
}
} }
composable(Screen.Transmission.route) { composable(Screen.Transmission.route,
// ComicScreen() enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) {
CardPage(title = "Tasks") {
TransmissionScreen(navigator = navController)
}
} }
composable(Screen.Me.route) {
MeScreen(); composable(Screen.Live.route,
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) {
LiveScreen()
}
composable(Screen.Me.route,
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) }) {
MeScreen()
} }
composable( composable(
route = Screen.VideoPlayer.route, route = Screen.VideoPlayer.route,
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
arguments = listOf(navArgument("videoId") { type = NavType.StringType }) arguments = listOf(navArgument("videoId") { type = NavType.StringType })
) { ) {
backStackEntry -> backStackEntry ->
@@ -142,6 +211,37 @@ fun AppNavigation() {
VideoPlayer(videoId = videoId, navController = navController) VideoPlayer(videoId = videoId, navController = navController)
} }
} }
composable(
route = Screen.ComicGrid.route,
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
arguments = listOf(navArgument("comicId") { type = NavType.StringType })
) {
backStackEntry ->
val comicId = backStackEntry.arguments?.getString("comicId")
if (comicId != null) {
ComicGridView(comicId = comicId, navController = navController)
}
}
composable(
route = Screen.ComicPage.route,
enterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
exitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Start, animationSpec = tween(200)) },
popEnterTransition = { slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
popExitTransition = { slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.End, animationSpec = tween(200)) },
arguments = listOf(navArgument("comicId") { type = NavType.StringType }, navArgument("page") { type = NavType.StringType })
) {
backStackEntry ->
val comicId = backStackEntry.arguments?.getString("comicId")
val page = backStackEntry.arguments?.getString("page")
if (comicId != null && page != null) {
ComicPageView(comicId = comicId, page = page, navController = navController)
}
}
} }
} }
} }
@@ -156,9 +256,10 @@ fun BottomNavigationBar(navController: NavController) {
Screen.Video, Screen.Video,
Screen.Comic, Screen.Comic,
Screen.Transmission, Screen.Transmission,
Screen.Live,
Screen.Me Screen.Me
) else listOf( ) else listOf(
Screen.Home, Screen.Video,
Screen.Transmission, Screen.Transmission,
Screen.Me Screen.Me
) )
@@ -183,12 +284,47 @@ 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")
data object Comic : Screen("comic_route", Icons.Filled.Image, "Comic") data object Comic : Screen("comic_route", Icons.Filled.Image, "Comic")
data object Transmission : Screen("transmission_route", data object Transmission : Screen("transmission_route",
Icons.AutoMirrored.Filled.CompareArrows, "Transmission") Icons.AutoMirrored.Filled.CompareArrows, "Transmission")
data object Live : Screen("live_route",
Icons.Filled.LiveTv, "Live")
data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me") data object Me : Screen("me_route", Icons.Filled.AccountCircle, "me")
data object VideoPlayer : Screen("video_player_route/{videoId}", Icons.Filled.PlayArrow, "VideoPlayer") data object VideoPlayer : Screen("video_player_route/{videoId}", Icons.Filled.PlayArrow, "VideoPlayer")
data object ComicGrid : Screen("comic_grid_route/{comicId}", Icons.Filled.PlayArrow, "ComicGrid")
data object ComicPage : Screen("comic_page_route/{comicId}/{page}", Icons.Filled.PlayArrow, "ComicPage")
} }

View File

@@ -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)
}
}

View File

@@ -1,5 +1,8 @@
package com.acitelight.aether.model package com.acitelight.aether.model
import kotlinx.serialization.Serializable
@Serializable
data class BookMark( data class BookMark(
val name: String, val name: String,
val page: String val page: String

View File

@@ -1,8 +1,81 @@
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
) )
{
fun getCover(api: ApiClient): String
{
if(id == "101")
print("")
if(comic.cover != "")
{
return "${api.getBase()}api/image/$id/${comic.cover}"
}
return "${api.getBase()}api/image/$id/${comic.list[0]}"
}
fun getPage(pageNumber: Int, api: ApiClient): String
{
return "${api.getBase()}api/image/$id/${comic.list[pageNumber]}"
}
fun getPage(pageName: String, api: ApiClient): String?
{
val v = comic.list.indexOf(pageName)
if(v >= 0)
{
return getPage(v, api)
}
return null
}
fun getPageIndex(pageName: String): Int
{
return comic.list.indexOf(pageName)
}
fun getChapterLength(pageName: String): Int
{
var v = comic.list.indexOf(pageName)
if(v >= 0)
{
var r = 1
v+=1
while(v < comic.list.size && !comic.bookmarks.any{
x -> x.page == comic.list[v]
}){
r++
v+=1
}
return r
}
return -1
}
fun getPageChapterIndex(page: Int): Pair<BookMark, Int>
{
var p = page
while(p >= 0 && !comic.bookmarks.any{ x -> x.page == comic.list[p] })
{
p--
}
if(p < 0) return Pair(BookMark(name="null", page=comic.list[0]), page + 1)
for(i in comic.bookmarks)
{
if(i.page == comic.list[p])
{
return Pair(i, page - comic.list.indexOf(i.page) + 1)
}
}
return Pair(BookMark(name="null", page=comic.list[0]), page + 1)
}
}

View 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
)

View File

@@ -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?
}

View File

@@ -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
}
}
}
}

View File

@@ -0,0 +1,14 @@
package com.acitelight.aether.model
import kotlinx.serialization.Serializable
@Serializable
data class ComicResponse(
val comic_name: String,
val page_count: Int,
val bookmarks: List<BookMark>,
val list: List<String>,
val tags: List<String>,
val author: String,
val cover: String
)

View File

@@ -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,

View File

@@ -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
) )

View File

@@ -1,29 +1,64 @@
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 video: VideoResponse val video: VideoResponse
){ ) {
fun getCover(): String fun getCover(api: ApiClient): String {
{ return if (isLocal)
return "${ApiClient.base}api/video/$klass/$id/cover?token=$token" "$localBase/videos/$klass/$id/cover.jpg"
else
"${api.getBase()}api/video/$klass/$id/cover"
} }
fun getVideo(): String fun getVideo(api: ApiClient): String {
{ return if (isLocal)
return "${ApiClient.base}api/video/$klass/$id/av?token=$token" "$localBase/videos/$klass/$id/video.mp4"
else
"${api.getBase()}api/video/$klass/$id/av"
} }
fun getGallery(): List<KeyImage> fun getSubtitle(api: ApiClient): 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
"${api.getBase()}api/video/$klass/$id/subtitle"
}
fun getGallery(api: ApiClient): List<KeyImage> {
return if (isLocal)
video.gallery.map {
KeyImage(
name = it,
url = "$localBase/videos/$klass/$id/gallery/$it",
key = "$klass/$id/gallery/$it"
)
} else video.gallery.map {
KeyImage(
name = it,
url = "${api.getBase()}api/video/$klass/$id/gallery/$it",
key = "$klass/$id/gallery/$it"
)
} }
} }
fun toLocal(localBase: String): Video
{
return Video(
isLocal = true,
localBase = localBase,
klass = klass,
id = id,
video = video
)
}
} }

View File

@@ -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)
}

View 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
)

View File

@@ -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?
}

View File

@@ -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
}
}
}
}

View File

@@ -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?
) )

View File

@@ -0,0 +1,368 @@
package com.acitelight.aether.service
import kotlinx.coroutines.*
import java.io.InputStream
import java.io.OutputStream
import java.net.Socket
import java.nio.ByteBuffer
import java.security.SecureRandom
import java.util.ArrayDeque
import org.bouncycastle.math.ec.rfc7748.X25519
import org.bouncycastle.crypto.generators.HKDFBytesGenerator
import org.bouncycastle.crypto.digests.SHA256Digest
import org.bouncycastle.crypto.params.HKDFParameters
import org.bouncycastle.crypto.params.KeyParameter
import org.bouncycastle.crypto.params.AEADParameters
import org.bouncycastle.crypto.modes.ChaCha20Poly1305
import java.io.EOFException
import java.util.concurrent.atomic.AtomicLong
class AbyssStream private constructor(
private val socket: Socket,
private val input: InputStream,
private val output: OutputStream,
private val aeadKey: ByteArray,
private val sendSalt: ByteArray,
private val recvSalt: ByteArray
) {
companion object {
private const val PUBLIC_KEY_LEN = 32
private const val AEAD_KEY_LEN = 32
private const val NONCE_SALT_LEN = 4
private const val AEAD_TAG_LEN = 16
private const val NONCE_LEN = 12
private const val MAX_PLAINTEXT_FRAME = 64 * 1024
private val secureRandom = SecureRandom()
/**
* Create and perform handshake on an already-connected socket.
* If privateKeyRaw is provided, it must be 32 bytes.
*/
suspend fun create(authManager: AuthManager, socket: Socket, privateKeyRaw: ByteArray? = null): AbyssStream = withContext(Dispatchers.IO) {
if (!socket.isConnected) throw IllegalArgumentException("socket is not connected")
val inStream = socket.getInputStream()
val outStream = socket.getOutputStream()
// 1) keypair (raw)
val localPriv = ByteArray(PUBLIC_KEY_LEN)
if (privateKeyRaw != null) {
if (privateKeyRaw.size != PUBLIC_KEY_LEN) {
throw IllegalArgumentException("privateKeyRaw must be $PUBLIC_KEY_LEN bytes")
}
System.arraycopy(privateKeyRaw, 0, localPriv, 0, PUBLIC_KEY_LEN)
} else {
X25519.generatePrivateKey(secureRandom, localPriv)
}
val localPub = ByteArray(PUBLIC_KEY_LEN)
X25519.scalarMultBase(localPriv, 0, localPub, 0)
// 2) exchange raw public keys (exact 32 bytes each) using blocking IO
writeExact(outStream, localPub, 0, PUBLIC_KEY_LEN)
val remotePub = ByteArray(PUBLIC_KEY_LEN)
readExact(inStream, remotePub, 0, PUBLIC_KEY_LEN)
val ch = ByteArray(32)
readExact(inStream, ch, 0, 32)
val signed = authManager.signChallengeByte(localPriv, ch)
writeExact(outStream, signed, 0, signed.size)
readExact(inStream, ch, 0, 16)
// 3) compute shared secret: X25519.scalarMult(private, remotePublic)
val shared = ByteArray(PUBLIC_KEY_LEN)
X25519.scalarMult(localPriv, 0, remotePub, 0, shared, 0)
// 4) HKDF-SHA256 -> AEAD key + saltA + saltB
val hkdf = HKDFBytesGenerator(SHA256Digest())
// AEAD key
hkdf.init(HKDFParameters(shared, null, "Abyss-AEAD-Key".toByteArray(Charsets.US_ASCII)))
val aeadKey = ByteArray(AEAD_KEY_LEN)
hkdf.generateBytes(aeadKey, 0, AEAD_KEY_LEN)
// salt A
hkdf.init(HKDFParameters(shared, null, "Abyss-Nonce-Salt-A".toByteArray(Charsets.US_ASCII)))
val saltA = ByteArray(NONCE_SALT_LEN)
hkdf.generateBytes(saltA, 0, NONCE_SALT_LEN)
// salt B
hkdf.init(HKDFParameters(shared, null, "Abyss-Nonce-Salt-B".toByteArray(Charsets.US_ASCII)))
val saltB = ByteArray(NONCE_SALT_LEN)
hkdf.generateBytes(saltB, 0, NONCE_SALT_LEN)
// Deterministic assignment by lexicographic comparison
val cmp = lexicographicCompare(localPub, remotePub)
val sendSalt: ByteArray
val recvSalt: ByteArray
if (cmp < 0) {
sendSalt = saltA
recvSalt = saltB
} else if (cmp > 0) {
sendSalt = saltB
recvSalt = saltA
} else {
// extremely unlikely
sendSalt = saltA
recvSalt = saltB
}
// zero sensitive buffers
localPriv.fill(0)
localPub.fill(0)
remotePub.fill(0)
shared.fill(0)
// keep aeadKey, sendSalt, recvSalt
return@withContext AbyssStream(socket, inStream, outStream, aeadKey, sendSalt, recvSalt)
}
private fun lexicographicCompare(a: ByteArray, b: ByteArray): Int {
val min = kotlin.math.min(a.size, b.size)
for (i in 0 until min) {
val av = a[i].toInt() and 0xff
val bv = b[i].toInt() and 0xff
if (av < bv) return -1
if (av > bv) return 1
}
if (a.size < b.size) return -1
if (a.size > b.size) return 1
return 0
}
private fun readExact(input: InputStream, buffer: ByteArray, offset: Int, count: Int) {
var read = 0
while (read < count) {
val n = input.read(buffer, offset + read, count - read)
if (n == -1) {
if (read == 0) throw EOFException("Remote closed connection while reading")
else throw EOFException("Remote closed connection unexpectedly during read")
}
read += n
}
}
private fun writeExact(output: OutputStream, buffer: ByteArray, offset: Int, count: Int) {
output.write(buffer, offset, count)
output.flush()
}
}
// internal state
private val sendCounter = AtomicLong(0L)
private val recvCounter = AtomicLong(0L)
private val sendLock = Any()
private val aeadLock = Any()
// leftover read queue
private val leftoverQueue = ArrayDeque<ByteArray>()
private var currentLeftover: ByteArray? = null
private var currentLeftoverOffset = 0
@Volatile
private var closed = false
// ---- high-level read/write APIs (suspendable) ----
suspend fun read(buffer: ByteArray, offset: Int, count: Int): Int = withContext(Dispatchers.IO) {
if (closed) throw IllegalStateException("AbyssStream closed")
if (buffer.size < offset + count) throw IndexOutOfBoundsException()
// serve leftover first
if (ensureCurrentLeftover()) {
val seg = currentLeftover!!
val avail = seg.size - currentLeftoverOffset
val toCopy = kotlin.math.min(avail, count)
System.arraycopy(seg, currentLeftoverOffset, buffer, offset, toCopy)
currentLeftoverOffset += toCopy
if (currentLeftoverOffset >= seg.size) {
currentLeftover = null
currentLeftoverOffset = 0
}
return@withContext toCopy
}
// read one frame and decrypt
val plaintext = readOneFrameAndDecrypt()
if (plaintext == null || plaintext.isEmpty()) {
// EOF
return@withContext 0
}
return@withContext if (plaintext.size <= count) {
System.arraycopy(plaintext, 0, buffer, offset, plaintext.size)
plaintext.size
} else {
System.arraycopy(plaintext, 0, buffer, offset, count)
val leftoverLen = plaintext.size - count
val leftover = ByteArray(leftoverLen)
System.arraycopy(plaintext, count, leftover, 0, leftoverLen)
synchronized(leftoverQueue) { leftoverQueue.addLast(leftover) }
count
}
}
private fun ensureCurrentLeftover(): Boolean {
if (currentLeftover != null && currentLeftoverOffset < currentLeftover!!.size) return true
synchronized(leftoverQueue) {
val next = leftoverQueue.pollFirst()
if (next != null) {
currentLeftover = next
currentLeftoverOffset = 0
return true
}
}
return false
}
private fun readOneFrameAndDecrypt(): ByteArray? {
// read 4-byte header
val header = ByteArray(4)
try {
readExact(input, header, 0, 4)
} catch (_: EOFException) {
return null
}
val payloadLen = ByteBuffer.wrap(header).int and 0xffffffff.toInt()
if (payloadLen > MAX_PLAINTEXT_FRAME + AEAD_TAG_LEN) throw IllegalStateException("payload too big")
if (payloadLen < AEAD_TAG_LEN) throw IllegalStateException("payload too small")
val payload = ByteArray(payloadLen)
readExact(input, payload, 0, payloadLen)
val ciphertextLen = payloadLen - AEAD_TAG_LEN
val ciphertext = ByteArray(ciphertextLen)
val tag = ByteArray(AEAD_TAG_LEN)
if (ciphertextLen > 0) System.arraycopy(payload, 0, ciphertext, 0, ciphertextLen)
System.arraycopy(payload, ciphertextLen, tag, 0, AEAD_TAG_LEN)
val remoteCounterValue = recvCounter.getAndIncrement()
val nonce = ByteArray(NONCE_LEN)
System.arraycopy(recvSalt, 0, nonce, 0, NONCE_SALT_LEN)
// write 8-byte big-endian counter at nonce[4..11]
val bb = ByteBuffer.wrap(nonce, NONCE_SALT_LEN, 8)
bb.putLong(remoteCounterValue)
val plaintext = try {
aeadDecrypt(nonce, ciphertext, tag)
} catch (ex: Exception) {
close()
throw SecurityException("AEAD authentication failed; connection closed.", ex)
} finally {
nonce.fill(0)
payload.fill(0)
ciphertext.fill(0)
tag.fill(0)
}
return plaintext
}
suspend fun write(buffer: ByteArray, offset: Int, count: Int) = withContext(Dispatchers.IO) {
if (closed) throw IllegalStateException("AbyssStream closed")
if (buffer.size < offset + count) throw IndexOutOfBoundsException()
var remaining = count
var idx = offset
while (remaining > 0) {
val chunk = kotlin.math.min(remaining, MAX_PLAINTEXT_FRAME)
val plaintext = buffer.copyOfRange(idx, idx + chunk)
sendPlaintextChunk(plaintext)
idx += chunk
remaining -= chunk
}
}
private fun sendPlaintextChunk(plaintext: ByteArray) {
if (closed) throw IllegalStateException("AbyssStream closed")
val nonce = ByteArray(NONCE_LEN)
val ciphertextAndTag: ByteArray
val counterValue: Long
synchronized(sendLock) {
counterValue = sendCounter.getAndIncrement()
}
System.arraycopy(sendSalt, 0, nonce, 0, NONCE_SALT_LEN)
ByteBuffer.wrap(nonce, NONCE_SALT_LEN, 8).putLong(counterValue)
try {
ciphertextAndTag = aeadEncrypt(nonce, plaintext)
} finally {
nonce.fill(0)
}
val payloadLen = ciphertextAndTag.size
// header + ciphertextAndTag 一次性合并
val packet = ByteBuffer.allocate(4 + payloadLen)
.putInt(payloadLen)
.put(ciphertextAndTag)
.array()
try {
synchronized(output) {
output.write(packet)
output.flush()
}
} finally {
// clear sensitive
ciphertextAndTag.fill(0)
plaintext.fill(0)
packet.fill(0)
}
}
// ---- AEAD helpers using BouncyCastle lightweight API ----
// ChaCha20-Poly1305 with 12-byte nonce. BouncyCastle ChaCha20Poly1305 produces ciphertext+tag.
private fun aeadEncrypt(nonce: ByteArray, plaintext: ByteArray): ByteArray {
synchronized(aeadLock) {
val cipher = ChaCha20Poly1305()
val params = AEADParameters(KeyParameter(aeadKey), AEAD_TAG_LEN * 8, nonce, null)
cipher.init(true, params)
val outBuf = ByteArray(cipher.getOutputSize(plaintext.size))
var len = cipher.processBytes(plaintext, 0, plaintext.size, outBuf, 0)
len += cipher.doFinal(outBuf, len)
if (len != outBuf.size) return outBuf.copyOf(len)
return outBuf
}
}
private fun aeadDecrypt(nonce: ByteArray, ciphertext: ByteArray, tag: ByteArray): ByteArray {
synchronized(aeadLock) {
val cipher = ChaCha20Poly1305()
val params = AEADParameters(KeyParameter(aeadKey), AEAD_TAG_LEN * 8, nonce, null)
cipher.init(false, params)
// input is ciphertext||tag
val input = ByteArray(ciphertext.size + tag.size)
if (ciphertext.isNotEmpty()) System.arraycopy(ciphertext, 0, input, 0, ciphertext.size)
System.arraycopy(tag, 0, input, ciphertext.size, tag.size)
val outBuf = ByteArray(cipher.getOutputSize(input.size))
var len = cipher.processBytes(input, 0, input.size, outBuf, 0)
try {
len += cipher.doFinal(outBuf, len)
} catch (ex: Exception) {
// authentication failure or other
throw ex
}
return if (len != outBuf.size) outBuf.copyOf(len) else outBuf
}
}
// ---- utility / lifecycle ----
fun close() {
if (!closed) {
closed = true
try { socket.close() } catch (_: Exception) {}
// clear secrets
aeadKey.fill(0)
sendSalt.fill(0)
recvSalt.fill(0)
synchronized(leftoverQueue) {
leftoverQueue.forEach { it.fill(0) }
leftoverQueue.clear()
}
currentLeftover = null
}
}
}

View File

@@ -0,0 +1,121 @@
package com.acitelight.aether.service
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.selects.select
import java.io.InputStream
import java.io.OutputStream
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.CoroutineContext
@Singleton
class AbyssTunnelProxy @Inject constructor(
private val settingsDataStoreManager: SettingsDataStoreManager,
private val authManager: AuthManager
) {
private val coroutineContext: CoroutineContext = Dispatchers.IO
private var serverHost: String = ""
private var serverPort: Int = 0
fun config(host: String, port: Int)
{
serverHost = host
serverPort = port
}
private val listenAddress = InetAddress.getLoopbackAddress()
private val listenPort = 4095
private var serverSocket: ServerSocket? = null
private val scope = CoroutineScope(SupervisorJob() + coroutineContext)
fun start() {
serverSocket = ServerSocket(listenPort, 50, listenAddress)
// accept loop
scope.launch {
val srv = serverSocket ?: return@launch
try {
while (true) {
val client = srv.accept()
if(serverHost.isEmpty())
continue
launch {
try { handleLocalConnection(client) }
catch (_: Exception) { /* ignore */ }
}
}
} catch (ex: Exception) {
println(ex.message)
// server stopped or fatal error
} finally {
stop()
}
}
}
fun stop() {
try { serverSocket?.close() } catch (_: Exception) {}
scope.cancel()
}
private suspend fun handleLocalConnection(localSocket: Socket) = withContext(coroutineContext) {
val localIn = localSocket.getInputStream()
val localOut = localSocket.getOutputStream()
var abyssSocket: Socket? = null
var abyssStream: AbyssStream? = null
try {
abyssSocket = Socket(serverHost, serverPort)
abyssStream = AbyssStream.create(authManager, abyssSocket, authManager.db64(settingsDataStoreManager.privateKeyFlow.first()))
// concurrently copy in both directions
val job1 = launch { copyExactSuspend(localIn, abyssStream) } // local -> abyss
val job2 = launch { copyFromAbyssToLocal(abyssStream, localOut) } // abyss -> local
// wait for either direction to finish
select {
job1.onJoin { /* completed */ }
job2.onJoin { /* completed */ }
}
// cancel other
job1.cancel()
job2.cancel()
} catch (ex: Exception)
{
println(ex.message)
// log or ignore; we close sockets below
} finally {
try { localSocket.close() } catch (_: Exception) {}
try { abyssStream?.close() } catch (_: Exception) {}
try { abyssSocket?.close() } catch (_: Exception) {}
}
return@withContext
}
// Copy from local InputStream into AbyssStream.write in frames.
private suspend fun copyExactSuspend(localIn: InputStream, abyss: AbyssStream) = withContext(coroutineContext) {
val buffer = ByteArray(64 * 1024)
while (true) {
val read = localIn.read(buffer)
if (read <= 0)
break
abyss.write(buffer, 0, read)
}
}
// Copy from AbyssStream (read frames/decrypt) to local OutputStream
private suspend fun copyFromAbyssToLocal(abyss: AbyssStream, localOut: OutputStream) = withContext(coroutineContext) {
val buffer = ByteArray(64 * 1024)
while (true) {
val n = abyss.read(buffer, 0, buffer.size)
if (n <= 0)
break
localOut.write(buffer, 0, n)
}
}
}

View File

@@ -2,33 +2,70 @@
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.franmontiel.persistentcookiejar.PersistentCookieJar
import com.franmontiel.persistentcookiejar.cache.SetCookieCache
import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.CertificatePinner import okhttp3.ConnectionSpec
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.EventListener
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.JavaNetCookieJar
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.net.CookieManager
import java.net.InetAddress
import java.net.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.inject.Inject
import javax.inject.Singleton
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
object ApiClient { @Singleton
var base: String = "" class ApiClient @Inject constructor(
var domain: String = "" @ApplicationContext private val context: Context,
var cert: String = "" ) {
fun getBase(): String{
return replaceAbyssProtocol(base)
}
fun getDomain(): String = domain
private var base: String = ""
private var domain: String = ""
private var cert: String = ""
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
} }
private fun replaceAbyssProtocol(uri: String): String {
fun loadCertificateFromString(pemString: String): X509Certificate { return uri.replaceFirst("^abyss://".toRegex(), "https://")
}
private val dnsEventListener = object : EventListener() {
override fun dnsEnd(call: okhttp3.Call, domainName: String, inetAddressList: List<InetAddress>) {
super.dnsEnd(call, domainName, inetAddressList)
val ipAddresses = inetAddressList.joinToString(", ") { it.hostAddress ?: "" }
Log.d("OkHttp_DNS", "Domain '$domainName' resolved to IPs: [$ipAddresses]")
}
}
private fun loadCertificateFromString(pemString: String): X509Certificate {
val certificateFactory = CertificateFactory.getInstance("X.509") val certificateFactory = CertificateFactory.getInstance("X.509")
val decodedPem = pemString val decodedPem = pemString
.replace("-----BEGIN CERTIFICATE-----", "") .replace("-----BEGIN CERTIFICATE-----", "")
@@ -41,66 +78,191 @@ object ApiClient {
return certificateFactory.generateCertificate(inputStream) as X509Certificate return certificateFactory.generateCertificate(inputStream) as X509Certificate
} }
} }
private fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate?): OkHttpClient {
fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate): OkHttpClient {
try { try {
val 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 { i -> i 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)
} }
} }
private fun createOkHttp(): OkHttpClient {
return if (cert == "")
if (base.startsWith("abyss://"))
OkHttpClient
.Builder()
.cookieJar(
PersistentCookieJar(
SetCookieCache(),
SharedPrefsCookiePersistor(context)
)
)
.proxy(
Proxy(
Proxy.Type.HTTP,
InetSocketAddress("::1", 4095)
)
)
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS))
.eventListener(dnsEventListener)
.build()
else
OkHttpClient
.Builder()
.cookieJar(
PersistentCookieJar(
SetCookieCache(),
SharedPrefsCookiePersistor(context)
)
)
.connectionSpecs(listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS))
.eventListener(dnsEventListener)
.build()
else
createOkHttpClientWithDynamicCert(loadCertificateFromString(cert))
fun createOkHttp(): OkHttpClient
{
return createOkHttpClientWithDynamicCert(loadCertificateFromString(cert))
} }
private fun createRetrofit(): Retrofit { private fun createRetrofit(): Retrofit {
val okHttpClient = createOkHttpClientWithDynamicCert(loadCertificateFromString(cert)) client = createOkHttp()
val b = replaceAbyssProtocol(base)
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(base) .baseUrl(b)
.client(okHttpClient) .client(client!!)
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build() .build()
} }
private var client: OkHttpClient? = null
var api: ApiInterface? = null var api: ApiInterface? = null
fun apply(url: String, crt: String) fun getClient() = client!!
{
try{ suspend fun apply(context: Context, urls: String, crt: String): String? {
domain = url.toHttpUrlOrNull()?.host !! try {
client = createOkHttp()
val urlList = urls.split(";").map { it.trim() }
var selectedUrl: String? = null
for (url in urlList) {
val host = url.toUri().host
if (host != null && pingHost(host)) {
selectedUrl = url
break
}
}
if (selectedUrl == null) {
throw Exception("No reachable URL found")
}
domain = replaceAbyssProtocol(selectedUrl).toHttpUrlOrNull()?.host ?: ""
cert = crt cert = crt
base = url base = selectedUrl
withContext(Dispatchers.IO)
{
(context as AetherApp).abyssService?.proxy?.config(getBase().toUri().host!!, 4096)
}
api = createRetrofit().create(ApiInterface::class.java) api = createRetrofit().create(ApiInterface::class.java)
}catch (e: Exception) return base
{ } catch (_: 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 (_: Exception) {
false
} }
} }
} }

View File

@@ -1,46 +1,46 @@
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.ResponseBody import okhttp3.ResponseBody
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
interface ApiInterface { interface ApiInterface {
@GET("api/video") @GET("api/video")
suspend fun getVideoClasses( suspend fun getVideoClasses(
@Query("token") token: String
): List<String> ): List<String>
@GET("api/video/{klass}") @GET("api/video/{klass}")
suspend fun queryVideoClasses( suspend fun queryVideoClasses(
@Path("klass") klass: String, @Path("klass") klass: String
@Query("token") token: String
): List<String> ): List<String>
@GET("api/video/{klass}/{id}") @GET("api/video/{klass}/{id}")
suspend fun queryVideo( suspend fun queryVideo(
@Path("klass") klass: String, @Path("klass") klass: String,
@Path("id") id: String, @Path("id") id: String
@Query("token") token: String
): VideoResponse ): VideoResponse
@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 ): List<VideoResponse>
): ResponseBody
@GET("api/image/collections") @GET("api/image")
suspend fun getComicCollections(): List<String> suspend fun getComics(): 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): ComicResponse
@POST("api/image/bulkquery")
suspend fun queryComicInfoBulk(@Body() id: List<String>): List<ComicResponse>
@POST("api/image/{id}/bookmark")
suspend fun postBookmark(@Path("id") id: String, @Body bookmark: BookMark)
@GET("api/user/{user}") @GET("api/user/{user}")
suspend fun getChallenge( suspend fun getChallenge(
@@ -52,4 +52,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
} }

View File

@@ -1,29 +1,45 @@
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
import javax.inject.Inject
import javax.inject.Singleton
object AuthManager { @Singleton
class AuthManager @Inject constructor(
private val apiClient: ApiClient
) {
suspend fun fetchToken(username: String, privateKey: String): String? { suspend fun fetchToken(username: String, privateKey: String): String? {
val api = ApiClient.api val api = apiClient.api
var challengeBase64 = "" var challengeBase64 = ""
try{ try{
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 +60,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()
}
} }

View File

@@ -0,0 +1,182 @@
package com.acitelight.aether.service
import android.content.Context
import com.acitelight.aether.model.Video
import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.Fetch
import com.tonyodev.fetch2.FetchConfiguration
import com.tonyodev.fetch2.FetchListener
import com.tonyodev.fetch2.Request
import com.tonyodev.fetch2core.Extras
import com.tonyodev.fetch2okhttp.OkHttpDownloader
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FetchManager @Inject constructor(
@ApplicationContext private val context: Context,
private val apiClient: ApiClient
) {
private var fetch: Fetch? = null
private var listener: FetchListener? = null
val configured = MutableStateFlow(false)
fun init() {
val fetchConfiguration = FetchConfiguration.Builder(context)
.setDownloadConcurrentLimit(8)
.setHttpDownloader(OkHttpDownloader(apiClient.getClient()))
.build()
fetch = Fetch.Impl.getInstance(fetchConfiguration)
configured.update { true }
}
// listener management
suspend fun setListener(l: FetchListener) {
configured.filter { it }.first()
listener?.let { fetch?.removeListener(it) }
listener = l
fetch?.addListener(l)
}
fun removeListener() {
listener?.let {
fetch?.removeListener(it)
}
listener = null
}
suspend fun getAllDownloadsAsync(): List<Download> {
configured.filter { it }.first()
val completed = MutableStateFlow(false)
var r = listOf<Download>()
fetch?.getDownloads { list ->
r = list
completed.update { true }
}
completed.filter { it }.first()
return r
}
// operations
fun pause(id: Int) {
fetch?.pause(id)
}
fun resume(id: Int) {
fetch?.resume(id)
}
fun retry(id: Int) {
fetch?.retry(id)
}
fun cancel(id: Int) {
fetch?.cancel(id)
}
fun delete(id: Int, callback: (() -> Unit)? = null) {
fetch?.delete(id) {
callback?.invoke()
} ?: callback?.invoke()
}
private suspend fun enqueue(
request: Request,
onEnqueued: ((Request) -> Unit)? = null,
onError: ((com.tonyodev.fetch2.Error) -> Unit)? = null
) {
configured.filter { it }.first()
fetch?.enqueue(request, { r -> onEnqueued?.invoke(r) }, { e -> onError?.invoke(e) })
}
private fun makeFolder(video: Video) {
val appFilesDir = context.getExternalFilesDir(null)
val videosDir = File(appFilesDir, "videos/${video.klass}/${video.id}/gallery")
videosDir.mkdirs()
}
suspend fun startVideoDownload(video: Video) {
if(getAllDownloadsAsync().any{
it.extras.getString("class", "") == video.klass && it.extras.getString("id", "") == video.id })
return
makeFolder(video)
File(
context.getExternalFilesDir(null),
"videos/${video.klass}/${video.id}/summary.json"
).writeText(Json.encodeToString(video))
val videoPath =
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/video.mp4")
val coverPath =
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/cover.jpg")
val subtitlePath = File(
context.getExternalFilesDir(null),
"videos/${video.klass}/${video.id}/subtitle.vtt"
)
val requests = mutableListOf(
Request(video.getVideo(apiClient), videoPath.path).apply {
extras = Extras(
mapOf(
"name" to video.video.name,
"id" to video.id,
"class" to video.klass,
"type" to "main"
)
)
},
Request(video.getCover(apiClient), coverPath.path).apply {
extras = Extras(
mapOf(
"name" to video.video.name,
"id" to video.id,
"class" to video.klass,
"type" to "cover"
)
)
},
Request(video.getSubtitle(apiClient), subtitlePath.path).apply {
extras = Extras(
mapOf(
"name" to video.video.name,
"id" to video.id,
"class" to video.klass,
"type" to "subtitle"
)
)
},
)
for (p in video.getGallery(apiClient)) {
requests.add(
Request(p.url, File(
context.getExternalFilesDir(null),
"videos/${video.klass}/${video.id}/gallery/${p.name}"
).path).apply {
extras = Extras(
mapOf(
"name" to video.video.name,
"id" to video.id,
"class" to video.klass,
"type" to "gallery"
)
)
}
)
}
for (i in requests)
enqueue(i)
}
}

View File

@@ -1,62 +1,198 @@
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.acitelight.aether.model.VideoDownloadItemState
import kotlinx.coroutines.withContext import com.tonyodev.fetch2.Status
import java.io.IOException import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
object MediaManager @Singleton
class MediaManager @Inject constructor(
val fetchManager: FetchManager,
@ApplicationContext val context: Context,
private val apiClient: ApiClient
)
{ {
var token: String = "null"
suspend fun listVideoKlasses(): List<String> suspend fun listVideoKlasses(): List<String>
{ {
try try
{ {
val j = ApiClient.api!!.getVideoClasses(token) val j = apiClient.api!!.getVideoClasses()
return j.toList() return j.toList()
}catch(e: Exception) }catch(_: Exception)
{ {
return listOf() return listOf()
} }
} }
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)
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, model: VideoDownloadItemState): Video?
{ {
if(model.status == Status.COMPLETED)
{
val jsonString = File(
context.getExternalFilesDir(null),
"videos/$klass/$id/summary.json"
).readText()
return Json.decodeFromString<Video>(jsonString).toLocal(context.getExternalFilesDir(null)?.path!!)
}
try { try {
val j = ApiClient.api!!.queryVideo(klass, id, token) val j = apiClient.api!!.queryVideo(klass, id)
return Video(klass = klass, id = id, token=token, j) return Video(klass = klass, id = id, isLocal = false, localBase = "", video = j)
}catch (e: Exception) }catch (_: Exception)
{ {
return null return null
} }
} }
suspend fun listComics() : List<String> suspend fun queryVideo(klass: String, id: String): Video?
{ {
// TODO: try val downloaded = fetchManager.getAllDownloadsAsync().filter {
return ApiClient.api!!.getComicCollections() it.extras.getString("id", "") == id &&
it.extras.getString("class", "") == klass
}
if(downloaded.any{ it.status == Status.COMPLETED }
&& downloaded.all{ it.status == Status.COMPLETED || it.extras.getString("type", "") == "subtitle" })
{
val jsonString = File(
context.getExternalFilesDir(null),
"videos/$klass/$id/summary.json"
).readText()
return Json.decodeFromString<Video>(jsonString).toLocal(context.getExternalFilesDir(null)?.path!!)
}
try {
val j = apiClient.api!!.queryVideo(klass, id)
return Video(klass = klass, id = id, isLocal = false, localBase = "", video = j)
}catch (_: Exception)
{
return null
}
} }
suspend fun queryComicInfo(c: String) : Comic suspend fun queryVideoBulk(klass: String, id: List<String>): List<Video>? {
return try {
val downloads = fetchManager.getAllDownloadsAsync()
val localIds = mutableSetOf<String>()
val remoteIds = mutableListOf<String>()
for (videoId in id) {
val o = downloads.filter {
it.extras.getString("id", "") == videoId &&
it.extras.getString("class", "") == klass
}
if (o.any{ it.status == Status.COMPLETED }
&& o.all{ it.status == Status.COMPLETED || it.extras.getString("type", "") == "subtitle" })
{
localIds.add(videoId)
} else {
remoteIds.add(videoId)
}
}
val localVideos = localIds.mapNotNull { videoId ->
val localFile = File(
context.getExternalFilesDir(null),
"videos/$klass/$videoId/summary.json"
)
if (localFile.exists()) {
try {
val jsonString = localFile.readText()
Json.decodeFromString<Video>(jsonString).toLocal(
context.getExternalFilesDir(null)?.path ?: ""
)
} catch (_: Exception) {
null
}
} else {
null
}
}
val remoteVideos = if (remoteIds.isNotEmpty()) {
val j = apiClient.api!!.queryVideoBulk(klass, remoteIds)
j.zip(remoteIds).map {
Video(
klass = klass,
id = it.second,
isLocal = false,
localBase = "",
video = it.first
)
}
} else {
emptyList()
}
localVideos + remoteVideos
} catch (_: Exception) {
null
}
}
suspend fun listComics() : List<String>
{ {
// TODO: try try{
return ApiClient.api!!.queryComicInfo(c) val j = apiClient.api!!.getComics()
return j.sorted()
}catch (_: Exception)
{
return listOf()
}
}
suspend fun queryComicInfoSingle(id: String) : Comic?
{
try{
val j = apiClient.api!!.queryComicInfo(id)
return Comic(id = id, comic = j)
}catch (_: Exception)
{
return null
}
}
suspend fun queryComicInfoBulk(id: List<String>) : List<Comic>?
{
try{
val j = apiClient.api!!.queryComicInfoBulk(id)
return j.zip(id).map { Comic(id = it.second, comic = it.first) }
}catch (_: Exception)
{
return null
}
}
suspend fun postBookmark(id: String, bookMark: BookMark): Boolean
{
try{
apiClient.api!!.postBookmark(id, bookMark)
return true
}catch (_: Exception)
{
return false
}
} }
} }

View File

@@ -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>()
} }

View File

@@ -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()
}
}
}

View File

@@ -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()
}

View File

@@ -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
} }

View File

@@ -1,11 +0,0 @@
package com.acitelight.aether.view
import androidx.compose.runtime.Composable
import androidx.lifecycle.viewmodel.compose.viewModel
import com.acitelight.aether.viewModel.ComicScreenViewModel
@Composable
fun ComicScreen(comicScreenViewModel: ComicScreenViewModel = viewModel())
{
}

View File

@@ -1,68 +0,0 @@
package com.acitelight.aether.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.acitelight.aether.Global
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.RecentManager
import com.acitelight.aether.viewModel.HomeScreenViewModel
@Composable
fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel(), navController: NavController)
{
if(Global.loggedIn)
homeScreenViewModel.Init()
LazyColumn(modifier = Modifier.fillMaxWidth())
{
item()
{
Column {
Text(
text = "Recent",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(16.dp).align(Alignment.Start)
)
HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color)
for(i in RecentManager.recent)
{
MiniVideoCard(
modifier = Modifier
.padding(horizontal = 12.dp),
i,
{
Global.sameClassVideos = RecentManager.recent
val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }"
navController.navigate(route)
}, homeScreenViewModel.imageLoader!!)
HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color)
}
}
}
}
}

View File

@@ -1,985 +0,0 @@
package com.acitelight.aether.view
import android.app.Activity
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.navigation.NavHostController
import com.acitelight.aether.viewModel.VideoPlayerViewModel
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.ThumbDown
import androidx.compose.material.icons.filled.ThumbUp
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import coil3.ImageLoader
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.Global
import com.acitelight.aether.ToggleFullScreen
import com.acitelight.aether.model.KeyImage
import com.acitelight.aether.model.Video
fun formatTime(ms: Long): String {
if (ms <= 0) return "00:00:00"
val totalSeconds = ms / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d:%02d", hours, minutes, seconds)
}
@Composable
fun isLandscape(): Boolean {
val configuration = LocalConfiguration.current
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BiliStyleSlider(
modifier: Modifier = Modifier,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f
) {
val thumbRadius = 6.dp
val trackHeight = 3.dp
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
modifier = modifier,
colors = SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF), // B站粉色
activeTrackColor = Color(0xFFFF6699),
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
),
track = { sliderPositions ->
Box(
Modifier
.height(trackHeight)
.fillMaxWidth()
.background(Color.LightGray.copy(alpha = 0.3f), RoundedCornerShape(50))
) {
Box(
Modifier
.align(Alignment.CenterStart)
.fillMaxWidth(value)
.fillMaxHeight()
.background(Color(0xFFFF6699), RoundedCornerShape(50))
)
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BiliMiniSlider(
modifier: Modifier = Modifier,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f
) {
val thumbRadius = 6.dp
val trackHeight = 3.dp
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
modifier = modifier,
colors = SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF), // B站粉色
activeTrackColor = Color(0xFFFF6699),
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
),
thumb = {
},
track = { sliderPositions ->
Box(
Modifier
.height(trackHeight)
.fillMaxWidth()
.background(Color.LightGray.copy(alpha = 0.3f), RoundedCornerShape(50))
) {
Box(
Modifier
.align(Alignment.CenterStart)
.fillMaxWidth(value)
.fillMaxHeight()
.background(Color(0xFFFF6699), RoundedCornerShape(50))
)
}
}
)
}
@Composable
fun VideoPlayer(
videoPlayerViewModel: VideoPlayerViewModel = viewModel(),
videoId: String,
navController: NavHostController
) {
videoPlayerViewModel.Init(videoId)
if(videoPlayerViewModel.startPlaying)
{
if (isLandscape()) {
VideoPlayerLandscape(videoPlayerViewModel)
}
else
{
VideoPlayerPortal(videoPlayerViewModel, navController)
}
}
}
@Composable
fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel, cover: Float)
{
val exoPlayer: ExoPlayer = videoPlayerViewModel._player!!;
val context = LocalContext.current
val activity = context as? Activity
Box(modifier)
{
AndroidView(
factory = {
PlayerView(
it
).apply {
player = exoPlayer
useController = false
}
},
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = {
videoPlayerViewModel.dragging = true
videoPlayerViewModel.planeVisibility = true
exoPlayer.pause()
},
onDragEnd = {
videoPlayerViewModel.dragging = false
if (videoPlayerViewModel.isPlaying)
exoPlayer.play()
},
onDrag = { change, dragAmount ->
exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong())
videoPlayerViewModel.playProcess = exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat()
}
)
}
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
onTap = {
videoPlayerViewModel.planeVisibility =
!videoPlayerViewModel.planeVisibility
},
onLongPress = {
videoPlayerViewModel.isLongPressing = true
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(3.0f)
},
onPress = { offset ->
val pressResult = tryAwaitRelease()
if (pressResult && videoPlayerViewModel.isLongPressing) {
videoPlayerViewModel.isLongPressing = false
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(1.0f)
}
},
)
}
)
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.isLongPressing,
enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }),
modifier = Modifier
.align(Alignment.TopCenter)
)
{
Box(modifier = Modifier.align(Alignment.TopCenter).padding(top = 24.dp).background(Color(0x44000000), RoundedCornerShape(18)))
{
Row{
Icon(
imageVector = Icons.Filled.FastForward,
contentDescription = "Fast Forward",
tint = Color.White,
modifier = Modifier.size(36.dp).padding(4.dp).align(Alignment.CenterVertically)
)
Text(
text = "3X Speed...",
modifier = Modifier.padding(4.dp).align(Alignment.CenterVertically),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFFFFFF)
)
}
}
}
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.dragging,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${
formatTime(
(exoPlayer.duration).toLong()
)
}",
fontWeight = FontWeight.Bold,
modifier = Modifier
.padding(bottom = 12.dp),
fontSize = 18.sp
)
}
if(cover > 0.0f)
Spacer(Modifier.background(Color(0x00FF6699 - 0x00222222 + ((0x000000FF * cover).toLong() shl 24) )).fillMaxSize())
androidx.compose.animation.AnimatedVisibility(
visible = !videoPlayerViewModel.planeVisibility,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter)
) {
BiliMiniSlider(
value = videoPlayerViewModel.playProcess,
onValueChange = {},
modifier = Modifier
.height(4.dp)
.align(Alignment.BottomCenter)
)
}
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.planeVisibility,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter).height(42.dp)
)
{
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 2.dp)
.align(Alignment.BottomCenter),
horizontalArrangement = Arrangement.SpaceBetween,
) {
IconButton(
onClick = {
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
Modifier
.size(36.dp)
.align(Alignment.CenterVertically)
) {
Icon(
imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
BiliStyleSlider(
value = videoPlayerViewModel.playProcess,
onValueChange = { value ->
exoPlayer.seekTo((exoPlayer.duration * value).toLong())
},
modifier = Modifier
.height(8.dp)
.align(Alignment.CenterVertically)
.weight(1f)
)
Text(
text = formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong()),
maxLines = 1,
fontSize = 12.sp,
color = Color(0xFFFFFFFF),
fontWeight = FontWeight.Bold,
modifier = Modifier
.width(80.dp)
.align(Alignment.CenterVertically)
.padding(start = 12.dp)
)
IconButton(
onClick = {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
},
Modifier
.size(36.dp)
.align(Alignment.CenterVertically)
) {
Icon(
imageVector = Icons.Default.Fullscreen,
contentDescription = "Fullscreen",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
}
}
}
}
@Composable
fun VideoPlayerPortal(videoPlayerViewModel: VideoPlayerViewModel, navController: NavHostController)
{
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp;
val minHeight = 42.dp
var coverAlpha by remember{ mutableFloatStateOf(0.0f) }
var maxHeight = remember { screenHeight * 0.65f }
var posed = remember { false }
val dens = LocalDensity.current
val listState = rememberLazyListState()
var playerHeight by remember { mutableStateOf(screenHeight * 0.65f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val deltaY = available.y // px
val deltaDp = with(dens) { deltaY.toDp() }
val r = if (deltaY < 0 && playerHeight > minHeight) {
val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight)
val consumedDp = newHeight - playerHeight
playerHeight = newHeight
val consumedPx = with(dens) { consumedDp.toPx() }
Offset(0f, consumedPx)
} else if(deltaY > 0 && playerHeight < maxHeight && listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0) {
val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight)
val consumedDp = newHeight - playerHeight
playerHeight = newHeight
val consumedPx = with(dens) { consumedDp.toPx() }
Offset(0f, consumedPx)
} else {
Offset.Zero
}
val dh = playerHeight - minHeight;
coverAlpha = (if(dh > 10.dp)
0f
else
(10.dp.value - dh.value) / 10.0f)
return r
}
}
}
ToggleFullScreen(false)
Column(Modifier.nestedScroll(nestedScrollConnection).fillMaxHeight())
{
PortalCorePlayer(
Modifier
.padding(top = 42.dp)
.heightIn(max = playerHeight)
.onGloballyPositioned { layoutCoordinates ->
if(!posed && videoPlayerViewModel.renderedFirst)
{
maxHeight = with(dens) {layoutCoordinates.size.height.toDp()}
playerHeight = maxHeight
posed = true
}
},
videoPlayerViewModel = videoPlayerViewModel, coverAlpha)
Row()
{
TabRow (
selectedTabIndex = videoPlayerViewModel.tabIndex,
modifier = Modifier.height(38.dp).fillMaxWidth(0.6f)
) {
Tab(
selected = videoPlayerViewModel.tabIndex == 0,
onClick = { videoPlayerViewModel.tabIndex = 0 },
text = { Text(text = "Introduction", maxLines = 1) },
modifier = Modifier.height(38.dp)
)
Tab(
selected = videoPlayerViewModel.tabIndex == 1,
onClick = { videoPlayerViewModel.tabIndex = 1 },
text = { Text(text = "Comment", maxLines = 1) },
modifier = Modifier.height(38.dp)
)
}
}
LazyColumn(state = listState, modifier = Modifier.fillMaxWidth()) {
item{
HorizontalDivider(Modifier, 2.dp, DividerDefaults.color)
Text(
modifier = Modifier.align(Alignment.Start).padding(horizontal = 12.dp).padding(top = 12.dp),
text = videoPlayerViewModel.video?.video?.name ?: "",
fontSize = 16.sp,
maxLines = 2,
fontWeight = FontWeight.Bold,
)
Row(Modifier.align(Alignment.Start).padding(horizontal = 4.dp).alpha(0.5f)) {
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = videoPlayerViewModel.video?.klass ?: "",
fontSize = 14.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = formatTime(videoPlayerViewModel.video?.video?.duration ?: 0),
fontSize = 14.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color)
SocialPanel(Modifier.align(Alignment.CenterHorizontally).fillMaxWidth(), videoPlayerViewModel = videoPlayerViewModel)
HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color)
HorizontalGallery(videoPlayerViewModel)
HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color)
for(i in Global.sameClassVideos ?: listOf())
{
if(i.id == videoPlayerViewModel.video?.id) continue
MiniVideoCard(
modifier = Modifier
.padding(horizontal = 12.dp),
i,
{
videoPlayerViewModel.isPlaying = false
videoPlayerViewModel._player?.pause()
val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }"
navController.navigate(route)
}, videoPlayerViewModel.imageLoader!!)
HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.25f), 1.dp, DividerDefaults.color)
}
}
}
}
}
@Composable
fun SocialPanel(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel)
{
Row(
modifier,
horizontalArrangement = Arrangement.Center
)
{
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
IconButton(
onClick = { },
modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.ThumbUp,
contentDescription = "ThumbUp",
tint = Color.Gray
)
}
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = videoPlayerViewModel.thumbUp.toString(),
fontSize = 12.sp,
maxLines = 1,
fontWeight = FontWeight.Bold)
}
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
IconButton(
onClick = { },
modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.ThumbDown,
contentDescription = "ThumbDown",
tint = Color.Gray
)
}
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = videoPlayerViewModel.thumbDown.toString(),
fontSize = 12.sp,
maxLines = 1,
fontWeight = FontWeight.Bold)
}
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
IconButton(
onClick = { videoPlayerViewModel.star = !videoPlayerViewModel.star },
modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.Star,
contentDescription = "Star",
tint = if(videoPlayerViewModel.star) Color(0xFFFF6699) else Color.Gray
)
}
}
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
IconButton(
onClick = { },
modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.Share,
contentDescription = "Forward",
tint = Color.Gray
)
}
}
Column(modifier = Modifier.padding(horizontal = 12.dp)) {
IconButton(
onClick = { },
modifier = Modifier.padding(horizontal = 4.dp).align(Alignment.CenterHorizontally).size(36.dp),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.Info,
contentDescription = "Detail",
tint = Color.Gray
)
}
}
}
}
@Composable
fun HorizontalGallery(videoPlayerViewModel: VideoPlayerViewModel)
{
LazyRow(
modifier = Modifier.fillMaxWidth().height(120.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 24.dp)
) {
items(videoPlayerViewModel.video?.getGallery() ?: listOf()) { it ->
SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!)
}
}
}
@Composable
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
)
}
@Composable
fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel)
{
val context = LocalContext.current
val activity = context as? Activity
val exoPlayer: ExoPlayer = videoPlayerViewModel._player!!;
BackHandler {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
ToggleFullScreen(true)
Box(Modifier.fillMaxSize())
{
Box(
modifier = Modifier
.background(Color.Black).align(Alignment.Center)
)
{
AndroidView(
factory = {
PlayerView(
it
).apply {
player = exoPlayer
useController = false
}
},
modifier = Modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = {
videoPlayerViewModel.planeVisibility = true
videoPlayerViewModel.dragging = true;
exoPlayer.pause()
},
onDragEnd = {
videoPlayerViewModel.dragging = false;
if (videoPlayerViewModel.isPlaying)
exoPlayer.play()
},
onDrag = { change, dragAmount ->
exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong())
videoPlayerViewModel.playProcess = exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat()
}
)
}
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
onTap = {
videoPlayerViewModel.planeVisibility =
!videoPlayerViewModel.planeVisibility
},
onLongPress = {
videoPlayerViewModel.isLongPressing = true
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(3.0f)
},
onPress = { offset ->
val pressResult = tryAwaitRelease()
if (pressResult && videoPlayerViewModel.isLongPressing) {
videoPlayerViewModel.isLongPressing = false
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(1.0f)
}
},
)
}
)
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.dragging,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
) {
Text(
text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${
formatTime(
(exoPlayer.duration).toLong()
)
}",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp),
fontSize = 18.sp
)
}
AnimatedVisibility(
visible = videoPlayerViewModel.isLongPressing,
enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }),
modifier = Modifier
.align(Alignment.TopCenter)
)
{
Box(modifier = Modifier.align(Alignment.TopCenter).padding(top = 24.dp).background(Color(0x44000000), RoundedCornerShape(18)))
{
Row{
Icon(
imageVector = Icons.Filled.FastForward,
contentDescription = "Fast Forward",
tint = Color.White,
modifier = Modifier.size(36.dp).padding(4.dp).align(Alignment.CenterVertically)
)
Text(
text = "3X Speed...",
modifier = Modifier.padding(4.dp).align(Alignment.CenterVertically),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFFFFFF)
)
}
}
}
IconButton(
onClick = {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
},
modifier = Modifier
.align(Alignment.TopStart)
.padding(8.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
AnimatedVisibility(
visible = videoPlayerViewModel.planeVisibility,
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
)
{
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background( brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.4f)
)
))
.padding(horizontal = 36.dp)
) {
Text(
text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${
formatTime(
(exoPlayer.duration).toLong()
)
}",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp),
fontSize = 12.sp
)
BiliStyleSlider(
value = videoPlayerViewModel.playProcess,
onValueChange = { value ->
exoPlayer.seekTo((exoPlayer.duration * value).toLong())
},
modifier = Modifier.height(16.dp).fillMaxWidth().padding(bottom = 8.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.align(Alignment.Start),
horizontalArrangement = Arrangement.SpaceBetween,
) {
IconButton(
onClick = {
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
Modifier.size(42.dp)
) {
Icon(
imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
tint = Color.White,
modifier = Modifier.size(42.dp)
)
}
}
}
}
}
}
}
@Composable
fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit, imageLoader: ImageLoader)
{
var isImageLoaded by remember { mutableStateOf(false) }
Card(
modifier = modifier.height(80.dp).fillMaxWidth(),
colors = CardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
disabledContentColor = Color.Transparent,
disabledContainerColor = Color.Transparent
),
onClick = onClick
)
{
Row()
{
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover())
.memoryCacheKey("${video.klass}/${video.id}/cover")
.diskCacheKey("${video.klass}/${video.id}/cover")
.listener(
onStart = { },
onSuccess = { _, _ -> isImageLoaded = true },
onError = { _, _ -> }
)
.build(),
contentDescription = null,
modifier = Modifier
.width(128.dp).fillMaxHeight()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
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,
)
}
}
}
}

View File

@@ -1,175 +0,0 @@
package com.acitelight.aether.view
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.acitelight.aether.model.Video
import com.acitelight.aether.viewModel.VideoScreenViewModel
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavHostController
import coil3.request.ImageRequest
import com.acitelight.aether.Global
import java.nio.charset.Charset
fun String.toHex(): String {
return this.toByteArray().joinToString("") { "%02x".format(it) }
}
fun String.hexToString(charset: Charset = Charsets.UTF_8): String {
require(length % 2 == 0) { "Hex string must have even length" }
val bytes = ByteArray(length / 2)
for (i in bytes.indices) {
val hexByte = substring(i * 2, i * 2 + 2)
bytes[i] = hexByte.toInt(16).toByte()
}
return String(bytes, charset)
}
@Composable
fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navController: NavHostController)
{
val tabIndex by videoScreenViewModel.tabIndex;
videoScreenViewModel.SetupClient()
Column(
modifier = Modifier.fillMaxSize() // 或至少 fillMaxWidth()
){
TopRow(videoScreenViewModel);
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
)
{
if(videoScreenViewModel.classes.isNotEmpty())
{
items(videoScreenViewModel.classesMap[videoScreenViewModel.classes[tabIndex]] ?: mutableStateListOf()) { video ->
VideoCard(video, navController, videoScreenViewModel)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopRow(videoScreenViewModel: VideoScreenViewModel)
{
val tabIndex by videoScreenViewModel.tabIndex;
if(videoScreenViewModel.classes.isEmpty()) return
ScrollableTabRow (selectedTabIndex = tabIndex) {
videoScreenViewModel.classes.forEachIndexed { index, title ->
Tab(
selected = tabIndex == index,
onClick = { videoScreenViewModel.setTabIndex(index) },
text = { Text(text = title, maxLines = 1) },
)
}
}
}
@Composable
fun VideoCard(video: Video, navController: NavHostController, videoScreenViewModel: VideoScreenViewModel) {
val tabIndex by videoScreenViewModel.tabIndex;
Card(
shape = RoundedCornerShape(6.dp),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
onClick = {
Global.sameClassVideos = videoScreenViewModel.classesMap[videoScreenViewModel.classes[tabIndex]] ?: mutableStateListOf()
val route = "video_player_route/${ "${video.klass}/${video.id}".toHex() }"
navController.navigate(route)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
Box(modifier = Modifier.fillMaxSize()){
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover())
.memoryCacheKey("${video.klass}/${video.id}/cover")
.diskCacheKey("${video.klass}/${video.id}/cover")
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxSize(),
contentScale = ContentScale.Crop,
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(
Modifier
.fillMaxWidth()
.height(24.dp)
.background( brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.45f)
)
))
.align(Alignment.BottomCenter))
}
Text(
text = video.video.name,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
modifier = Modifier.padding(8.dp).background(Color.Transparent).heightIn(48.dp)
)
Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("Class", fontSize = 12.sp)
Text("${video.klass}", fontSize = 12.sp)
}
}
}
}

View File

@@ -0,0 +1,103 @@
package com.acitelight.aether.view.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderColors
import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BiliStyleSlider(
modifier: Modifier = Modifier,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f
) {
val colorScheme = MaterialTheme.colorScheme
val trackHeight = 3.dp
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
modifier = modifier,
colors = SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF),
activeTrackColor = colorScheme.primary,
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
),
track = { sliderPositions ->
Box(
Modifier
.height(trackHeight)
.fillMaxWidth()
.background(Color.LightGray.copy(alpha = 0.3f), RoundedCornerShape(50))
) {
Box(
Modifier
.align(Alignment.CenterStart)
.fillMaxWidth(value)
.fillMaxHeight()
.background(colorScheme.primary, RoundedCornerShape(50))
)
}
}
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BiliMiniSlider(
modifier: Modifier = Modifier,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
colors: SliderColors = SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF),
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
)
) {
val trackHeight = 3.dp
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
modifier = modifier,
colors = colors,
enabled = false,
thumb = {
},
track = { sliderPositions ->
Box(
Modifier
.height(trackHeight)
.fillMaxWidth()
.background(colors.inactiveTrackColor, RoundedCornerShape(50))
) {
Box(
Modifier
.align(Alignment.CenterStart)
.fillMaxWidth(value)
.fillMaxHeight()
.background(colors.activeTrackColor, RoundedCornerShape(50))
)
}
}
)
}

View File

@@ -0,0 +1,60 @@
package com.acitelight.aether.view.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.DialogProperties
@Composable
fun BookmarkPop(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit
)
{
var inputValue by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("Bookmark", style = MaterialTheme.typography.headlineMedium)
},
text = {
Column {
OutlinedTextField(
value = inputValue,
onValueChange = { inputValue = it },
label = { Text("Bookmark") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(inputValue) },
enabled = inputValue.isNotBlank()
) {
Text("Confirm")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true
)
)
}

View File

@@ -0,0 +1,120 @@
package com.acitelight.aether.view.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.model.Comic
import com.acitelight.aether.view.pages.toHex
import com.acitelight.aether.viewModel.ComicScreenViewModel
@Composable
fun ComicCard(
comic: Comic,
navController: NavHostController,
comicScreenViewModel: ComicScreenViewModel
) {
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(0.65f)),
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
onClick = {
val route = "comic_grid_route/${comic.id.toHex()}"
navController.navigate(route)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(comic.getCover(comicScreenViewModel.apiClient))
.memoryCacheKey("${comic.id}/cover")
.diskCacheKey("${comic.id}/cover")
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit,
imageLoader = comicScreenViewModel.imageLoader!!,
)
Box(
Modifier
.fillMaxWidth()
.height(24.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.45f)
)
)
)
.align(Alignment.BottomCenter)
)
{
Text(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(2.dp),
fontSize = 12.sp,
text = "${comic.comic.list.size} Pages",
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1
)
}
}
Text(
text = comic.comic.comic_name,
fontSize = 12.sp,
lineHeight = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
modifier = Modifier
.padding(4.dp)
.heightIn(min = 14.dp)
)
Text(
text = "Id: ${comic.id}",
fontSize = 10.sp,
lineHeight = 12.sp,
maxLines = 1,
modifier = Modifier.padding(4.dp)
)
}
}
}

View File

@@ -0,0 +1,55 @@
package com.acitelight.aether.view.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import coil3.ImageLoader
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.model.KeyImage
import com.acitelight.aether.viewModel.VideoPlayerViewModel
@Composable
fun HorizontalGallery(videoPlayerViewModel: VideoPlayerViewModel) {
val gallery by videoPlayerViewModel.currentGallery
LazyRow(
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 24.dp)
) {
items(gallery) { it ->
SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!)
}
}
}
@Composable
private fun SingleImageItem(img: KeyImage, imageLoader: ImageLoader) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(img.url)
.memoryCacheKey(img.key)
.diskCacheKey(img.key)
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop,
imageLoader = imageLoader
)
}

View File

@@ -0,0 +1,136 @@
package com.acitelight.aether.view.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.ImageLoader
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.model.Video
import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.view.pages.formatTime
@Composable
fun MiniPlaylistCard(modifier: Modifier, video: Video, imageLoader: ImageLoader, selected: Boolean, apiClient: ApiClient, onClick: () -> Unit) {
val colorScheme = MaterialTheme.colorScheme
Card(
modifier = modifier
.height(80.dp)
.fillMaxWidth(),
colors = CardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
disabledContentColor = Color.Transparent,
disabledContainerColor = Color.Transparent
),
onClick = onClick
)
{
Row()
{
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover(apiClient))
.memoryCacheKey("${video.klass}/${video.id}/cover")
.diskCacheKey("${video.klass}/${video.id}/cover")
.listener(
onStart = { },
onError = { _, _ -> }
)
.build(),
contentDescription = null,
modifier = Modifier
.width(128.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.then(
if (selected)
Modifier.drawWithContent {
drawContent()
val strokeWidth = 3.dp.toPx()
val shape = RoundedCornerShape(8.dp)
val outline = shape.createOutline(size, layoutDirection, this)
drawOutline(
outline = outline,
color = colorScheme.primary,
style = Stroke(width = strokeWidth)
)
}
else
Modifier
),
contentScale = ContentScale.Crop,
imageLoader = imageLoader
)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxHeight()
.fillMaxWidth()
.align(Alignment.CenterVertically),
verticalArrangement = Arrangement.Center,
)
{
Text(
modifier = Modifier,
text = video.video.name,
fontSize = 13.sp,
maxLines = 2,
fontWeight = FontWeight.Bold,
lineHeight = 14.sp,
color = if(selected) colorScheme.primary else colorScheme.onSurface
)
Spacer(modifier.weight(1f))
Text(
modifier = Modifier.height(16.dp),
text = video.klass,
fontSize = 8.sp,
lineHeight = 9.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
color = if(selected) colorScheme.primary else colorScheme.onSurface
)
Text(
modifier = Modifier.height(16.dp),
text = formatTime(video.video.duration),
fontSize = 8.sp,
lineHeight = 9.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
color = if(selected) colorScheme.primary else colorScheme.onSurface
)
}
}
}
}

View File

@@ -0,0 +1,108 @@
package com.acitelight.aether.view.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardColors
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.ImageLoader
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.model.Video
import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.view.pages.formatTime
@Composable
fun MiniVideoCard(modifier: Modifier, video: Video, imageLoader: ImageLoader, apiClient: ApiClient, onClick: () -> Unit) {
Card(
modifier = modifier
.height(80.dp)
.fillMaxWidth(),
colors = CardColors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
disabledContentColor = Color.Transparent,
disabledContainerColor = Color.Transparent
),
onClick = onClick
)
{
Row()
{
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover(apiClient))
.memoryCacheKey("${video.klass}/${video.id}/cover")
.diskCacheKey("${video.klass}/${video.id}/cover")
.listener(
onStart = { },
onError = { _, _ -> }
)
.build(),
contentDescription = null,
modifier = Modifier
.width(128.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
imageLoader = imageLoader
)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxHeight()
.fillMaxWidth()
.align(Alignment.CenterVertically),
verticalArrangement = Arrangement.Center
)
{
Text(
modifier = Modifier,
text = video.video.name,
fontSize = 14.sp,
maxLines = 2,
fontWeight = FontWeight.Bold,
)
Spacer(modifier.weight(1f))
Text(
modifier = Modifier.height(16.dp),
text = video.klass,
fontSize = 8.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
Text(
modifier = Modifier.height(16.dp),
text = formatTime(video.video.duration),
fontSize = 8.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
}
}
}
}

View File

@@ -0,0 +1,92 @@
package com.acitelight.aether.view.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewModelScope
import com.acitelight.aether.viewModel.VideoPlayerViewModel
import kotlinx.coroutines.launch
@Composable
fun PlaylistPanel(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel) {
val colorScheme = MaterialTheme.colorScheme
val name by videoPlayerViewModel.currentName
val id by videoPlayerViewModel.currentId
val listState = rememberLazyListState()
val videos = videoPlayerViewModel.videos
LaunchedEffect(id, videos) {
val targetIndex = videos.indexOfFirst { it.id == id }
if (targetIndex >= 0) {
listState.scrollToItem(targetIndex)
}
}
LazyRow(
modifier = modifier
.fillMaxWidth()
.height(80.dp),
state = listState,
horizontalArrangement = Arrangement.spacedBy(6.dp),
contentPadding = PaddingValues(horizontal = 24.dp)
) {
items(videos) { it ->
Card(
modifier = Modifier
.fillMaxHeight()
.width(140.dp),
onClick = {
if (name == it.video.name)
return@Card
videoPlayerViewModel.viewModelScope.launch {
videoPlayerViewModel.startPlay(it)
}
},
colors =
if (it.id == id)
CardDefaults.cardColors(containerColor = colorScheme.primary)
else
CardDefaults.cardColors()
) {
Box(
Modifier
.padding(8.dp)
.fillMaxSize()
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = it.video.name,
maxLines = 4,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
lineHeight = 13.sp
)
}
}
}
}
}

View File

@@ -0,0 +1,430 @@
package com.acitelight.aether.view.components
import android.app.Activity
import android.content.Context
import android.media.AudioManager
import android.view.View
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Brightness4
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.Fullscreen
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import com.acitelight.aether.view.pages.formatTime
import com.acitelight.aether.view.pages.moveBrit
import com.acitelight.aether.viewModel.VideoPlayerViewModel
import kotlin.math.abs
@OptIn(UnstableApi::class)
@Composable
fun PortalCorePlayer(modifier: Modifier, videoPlayerViewModel: VideoPlayerViewModel, cover: Float) {
val exoPlayer: ExoPlayer = videoPlayerViewModel.player!!
val context = LocalContext.current
val activity = (context as? Activity)!!
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
var volFactor by remember {
mutableFloatStateOf(
audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume.toFloat()
)
}
fun setVolume(value: Int) {
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
value.coerceIn(0, maxVolume),
AudioManager.FLAG_PLAY_SOUND
)
}
Box(modifier)
{
AndroidView(
factory = {
PlayerView(
it
).apply {
player = exoPlayer
useController = false
subtitleView?.let { sv ->
sv.visibility = View.GONE
}
}
},
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.pointerInput(Unit) {
detectDragGestures(
onDragStart = { offset ->
if (videoPlayerViewModel.locked) return@detectDragGestures
if (offset.x < size.width / 2) {
videoPlayerViewModel.draggingPurpose = -1
} else {
videoPlayerViewModel.draggingPurpose = -2
}
},
onDragEnd = {
if (videoPlayerViewModel.isPlaying && videoPlayerViewModel.draggingPurpose == 0)
exoPlayer.play()
videoPlayerViewModel.draggingPurpose = -1
},
onDrag = { change, dragAmount ->
if (videoPlayerViewModel.locked) return@detectDragGestures
if (abs(dragAmount.x) > abs(dragAmount.y) &&
(videoPlayerViewModel.draggingPurpose == -1 || videoPlayerViewModel.draggingPurpose == -2)
) {
videoPlayerViewModel.draggingPurpose = 0
videoPlayerViewModel.planeVisibility = true
exoPlayer.pause()
} else if (videoPlayerViewModel.draggingPurpose == -1) videoPlayerViewModel.draggingPurpose =
1
else if (videoPlayerViewModel.draggingPurpose == -2) videoPlayerViewModel.draggingPurpose =
2
if (videoPlayerViewModel.draggingPurpose == 0) {
exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong())
videoPlayerViewModel.playProcess =
exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat()
} else if (videoPlayerViewModel.draggingPurpose == 2) {
val cu = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
volFactor = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
.toFloat() / maxVolume.toFloat()
if (dragAmount.y < 0)
setVolume(cu + 1)
else if (dragAmount.y > 0)
setVolume(cu - 1)
} else if (videoPlayerViewModel.draggingPurpose == 1) {
moveBrit(dragAmount.y, activity, videoPlayerViewModel)
}
}
)
}
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
if (videoPlayerViewModel.locked) return@detectTapGestures
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
onTap = {
if (videoPlayerViewModel.locked) return@detectTapGestures
videoPlayerViewModel.planeVisibility =
!videoPlayerViewModel.planeVisibility
},
onLongPress = {
if (videoPlayerViewModel.locked) return@detectTapGestures
videoPlayerViewModel.isLongPressing = true
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(3.0f)
},
onPress = { offset ->
val pressResult = tryAwaitRelease()
if (pressResult && videoPlayerViewModel.isLongPressing) {
videoPlayerViewModel.isLongPressing = false
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(1.0f)
}
},
)
}
)
AnimatedVisibility(
visible = videoPlayerViewModel.isLongPressing,
enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }),
modifier = Modifier
.align(Alignment.TopCenter)
)
{
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 24.dp)
.background(Color(0x44000000), RoundedCornerShape(18))
)
{
Row {
Icon(
imageVector = Icons.Filled.FastForward,
contentDescription = "Fast Forward",
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.align(Alignment.CenterVertically)
)
Text(
text = "3X Speed...",
modifier = Modifier
.padding(4.dp)
.align(Alignment.CenterVertically),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFFFFFF)
)
}
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 0,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Text(
text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${
formatTime(
(exoPlayer.duration)
)
}",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp),
fontSize = 18.sp
)
}
AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 2,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Row(Modifier
.background(Color(0x88000000), RoundedCornerShape(18))
.width(200.dp))
{
Icon(
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Vol",
tint = Color.White,
modifier = Modifier
.size(48.dp)
.padding(8.dp)
.align(Alignment.CenterVertically)
)
BiliMiniSlider(
value = volFactor,
onValueChange = {},
modifier = Modifier
.height(4.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
)
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 1,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Row(Modifier
.background(Color(0x88000000), RoundedCornerShape(18))
.width(200.dp))
{
Icon(
imageVector = Icons.Default.Brightness4,
contentDescription = "Brightness",
tint = Color.White,
modifier = Modifier
.size(48.dp)
.padding(8.dp)
.align(Alignment.CenterVertically)
)
BiliMiniSlider(
value = videoPlayerViewModel.brit,
onValueChange = {},
modifier = Modifier
.height(4.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
)
}
}
if (cover > 0.0f)
Spacer(Modifier
.background(MaterialTheme.colorScheme.primary.copy(cover))
.fillMaxSize())
AnimatedVisibility(
visible = (!videoPlayerViewModel.planeVisibility) || videoPlayerViewModel.locked,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
) {
BiliMiniSlider(
value = videoPlayerViewModel.playProcess,
onValueChange = {},
modifier = Modifier
.height(4.dp)
.align(Alignment.BottomCenter)
)
}
AnimatedVisibility(
visible = videoPlayerViewModel.planeVisibility && (!videoPlayerViewModel.locked),
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.height(42.dp)
)
{
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.4f),
)
)
),
horizontalArrangement = Arrangement.SpaceBetween,
) {
IconButton(
onClick = {
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
Modifier
.size(36.dp)
.align(Alignment.CenterVertically)
) {
Icon(
imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
BiliStyleSlider(
value = videoPlayerViewModel.playProcess,
onValueChange = { value ->
exoPlayer.seekTo((exoPlayer.duration * value).toLong())
},
modifier = Modifier
.height(8.dp)
.align(Alignment.CenterVertically)
.weight(1f)
)
Text(
text = formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong()),
maxLines = 1,
fontSize = 12.sp,
color = Color(0xFFFFFFFF),
fontWeight = FontWeight.Bold,
modifier = Modifier
.width(80.dp)
.align(Alignment.CenterVertically)
.padding(start = 12.dp)
)
IconButton(
onClick = {
videoPlayerViewModel.isLandscape = true
},
Modifier
.size(36.dp)
.align(Alignment.CenterVertically)
) {
Icon(
Icons.Default.Fullscreen,
contentDescription = "FullScreen",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
}
}
SubtitleOverlay(
cues = videoPlayerViewModel.cues,
modifier = Modifier.matchParentSize()
)
}
}

View File

@@ -0,0 +1,84 @@
package com.acitelight.aether.view.components
import android.text.Layout
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.media3.common.text.Cue
@Composable
fun SubtitleOverlay(
cues: List<Cue>,
modifier: Modifier = Modifier,
maxLines: Int = 2,
textSize: TextUnit = 14.sp,
backgroundAlpha: Float = 0.6f,
horizontalMargin: Dp = 16.dp,
bottomMargin: Dp = 14.dp,
contentPadding: Dp = 6.dp,
cornerRadius: Dp = 6.dp,
textColor: Color = Color.White
) {
val raw = if (cues.isEmpty()) "" else cues.joinToString(separator = "\n") {
it.text?.toString() ?: ""
}.trim()
if (raw.isEmpty()) return
val textAlign = when (cues.firstOrNull()?.textAlignment) {
Layout.Alignment.ALIGN_CENTER -> TextAlign.Center
Layout.Alignment.ALIGN_OPPOSITE -> TextAlign.End
Layout.Alignment.ALIGN_NORMAL -> TextAlign.Start
else -> TextAlign.Center
}
val blurPx = with(LocalDensity.current) { (2.dp).toPx() }
Box(
modifier = modifier,
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.padding(start = horizontalMargin, end = horizontalMargin, bottom = bottomMargin)
.wrapContentWidth(Alignment.CenterHorizontally)
.clip(RoundedCornerShape(cornerRadius))
.background(Color.Black.copy(alpha = backgroundAlpha))
.padding(horizontal = 12.dp, vertical = contentPadding)
) {
Text(
text = raw,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
style = TextStyle(
color = textColor,
fontSize = textSize,
shadow = Shadow(
color = Color.Black.copy(alpha = 0.85f),
offset = Offset(0f, 0f),
blurRadius = blurPx
)
),
textAlign = textAlign,
modifier = Modifier
)
}
}
}

View File

@@ -0,0 +1,186 @@
package com.acitelight.aether.view.components
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.Global.updateRelate
import com.acitelight.aether.model.Video
import com.acitelight.aether.view.pages.formatTime
import com.acitelight.aether.view.pages.toHex
import com.acitelight.aether.viewModel.VideoScreenViewModel
import kotlinx.coroutines.launch
@Composable
fun VideoCard(
videos: List<Video>,
navController: NavHostController,
videoScreenViewModel: VideoScreenViewModel
) {
val tabIndex by videoScreenViewModel.tabIndex;
val video = videos.first()
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(0.65f)),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.combinedClickable(
onClick = {
updateRelate(
videoScreenViewModel.videoLibrary.classesMap[videoScreenViewModel.videoLibrary.classes[tabIndex]]
?: mutableStateListOf(), video
)
val vg = videos.joinToString(",") { "${it.klass}/${it.id}" }.toHex()
val route = "video_player_route/$vg"
navController.navigate(route)
},
onLongClick = {
videoScreenViewModel.viewModelScope.launch {
for(i in videos)
{
videoScreenViewModel.download(i)
}
Toast.makeText(
videoScreenViewModel.context,
"Start downloading ${video.video.group}",
Toast.LENGTH_SHORT
).show()
}
}
),
shape = RoundedCornerShape(6.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth(),
) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover(videoScreenViewModel.apiClient))
.memoryCacheKey("${video.klass}/${video.id}/cover")
.diskCacheKey("${video.klass}/${video.id}/cover")
.build(),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Fit,
imageLoader = videoScreenViewModel.imageLoader!!
)
Box(
Modifier
.fillMaxWidth()
.height(24.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.6f)
)
)
)
.align(Alignment.BottomCenter)
)
Text(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 2.dp),
text = "${videos.size} Videos",
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
lineHeight = 13.sp,
color = Color.White
)
Text(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(horizontal = 2.dp),
text = formatTime(video.video.duration),
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
lineHeight = 13.sp,
color = Color.White
)
if (videos.all{ it.isLocal })
Card(
Modifier
.align(Alignment.TopStart)
.padding(5.dp)
.widthIn(max = 46.dp)
) {
Box(Modifier.fillMaxWidth())
{
Text(
modifier = Modifier.align(Alignment.Center),
text = "Local",
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
}
}
}
Text(
text = video.video.group ?: video.video.name,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
modifier = Modifier
.padding(4.dp)
.background(Color.Transparent)
.heightIn(min = 24.dp),
lineHeight = 14.sp
)
Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier.padding(horizontal = 4.dp).fillMaxWidth()
) {
Text(modifier = Modifier.align(Alignment.CenterStart), text = "Class: ${video.klass}", fontSize = 10.sp, maxLines = 1)
Text(modifier = Modifier.align(Alignment.CenterEnd), text = "Id: ${
videos.take(5).joinToString(
","
) { it.id }
}", fontSize = 10.sp, maxLines = 1)
}
}
}
}

View File

@@ -0,0 +1,275 @@
package com.acitelight.aether.view.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.Global.updateRelate
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoDownloadItemState
import com.acitelight.aether.view.pages.toHex
import com.acitelight.aether.viewModel.TransmissionScreenViewModel
import com.tonyodev.fetch2.Status
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import kotlin.math.abs
@Composable
fun VideoDownloadCard(
navigator: NavHostController,
viewModel: TransmissionScreenViewModel,
model: VideoDownloadItemState,
onPause: () -> Unit,
onResume: () -> Unit,
onCancel: () -> Unit,
onDelete: () -> Unit,
onRetry: () -> Unit
) {
Card(
shape = RoundedCornerShape(8.dp),
elevation = CardDefaults.cardElevation(4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.background(Color.Transparent)
.clickable(onClick = {
if (model.status == Status.COMPLETED) {
viewModel.viewModelScope.launch(Dispatchers.IO)
{
val downloaded = viewModel.fetchManager.getAllDownloadsAsync().filter {
it.status == Status.COMPLETED && it.extras.getString(
"class",
""
) != "comic" && it.extras.getString(
"type",
""
) == "main"
}
val jsonQuery = downloaded.map {
File(
viewModel.context.getExternalFilesDir(null),
"videos/${
it.extras.getString(
"class",
""
)
}/${it.extras.getString("id", "")}/summary.json"
).readText()
}
.map {
Json.decodeFromString<Video>(it)
.toLocal(viewModel.context.getExternalFilesDir(null)!!.path)
}
updateRelate(
jsonQuery,
jsonQuery.first { it.id == model.vid && it.klass == model.klass }
)
val playList = mutableListOf<String>()
val fv = viewModel.videoLibrary.classesMap.map { it.value }.flatten()
val video = fv.firstOrNull { it.klass == model.klass && it.id == model.vid }
if (video != null) {
val group = fv.filter { it.klass == video.klass && it.video.group == video.video.group }
for (i in group.sortedWith(compareBy(naturalOrder()) { it.video.name })) {
playList.add("${i.klass}/${i.id}")
}
}
val route = "video_player_route/${(playList.joinToString(",") + "|${model.vid}").toHex()}"
withContext(Dispatchers.Main) {
navigator.navigate(route)
}
}
}
})
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = model.fileName, style = MaterialTheme.typography.titleMedium, maxLines = 2)
// Text(text = model.filePath, style = MaterialTheme.typography.titleSmall)
}
}
Box(
Modifier
.fillMaxWidth()
.padding(top = 5.dp)
)
{
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.align(Alignment.CenterStart)
) {
val video = viewModel.modelToVideo(model)
if (video == null)
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(
File(
viewModel.context.getExternalFilesDir(null),
"videos/${model.klass}/${model.vid}/cover.jpg"
)
)
.memoryCacheKey("${model.klass}/${model.vid}/cover")
.diskCacheKey("${model.klass}/${model.vid}/cover")
.build(),
contentDescription = null,
modifier = Modifier.height(100.dp),
contentScale = ContentScale.Fit
)
else {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover(viewModel.apiClient))
.memoryCacheKey("${model.klass}/${model.vid}/cover")
.diskCacheKey("${model.klass}/${model.vid}/cover")
.build(),
contentDescription = null,
modifier = Modifier.height(100.dp),
contentScale = ContentScale.Fit,
imageLoader = viewModel.imageLoader!!
)
}
}
Column(Modifier.align(Alignment.BottomEnd)) {
Text(
text = "${model.progress.coerceIn(0, 100)}%",
modifier = Modifier
.padding(start = 8.dp)
.align(Alignment.End)
)
Text(
modifier = Modifier
.padding(start = 8.dp)
.align(Alignment.End),
text = "%.2f MB/%.2f MB".format(
model.downloadedBytes / (1024.0 * 1024.0),
model.totalBytes / (1024.0 * 1024.0)
),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
// progress bar
LinearProgressIndicator(
progress = { abs(model.progress).coerceIn(0, 100) / 100f },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 8.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
// action buttons
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
when (model.status) {
Status.DOWNLOADING -> {
Button(onClick = onPause) {
Icon(imageVector = Icons.Default.Pause, contentDescription = "Pause")
Text(text = " Pause", modifier = Modifier.padding(start = 6.dp))
}
Button(onClick = onCancel) {
Icon(imageVector = Icons.Default.Stop, contentDescription = "Cancel")
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
}
}
Status.PAUSED, Status.QUEUED -> {
Button(onClick = onResume) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Resume"
)
Text(text = " Resume", modifier = Modifier.padding(start = 6.dp))
}
Button(onClick = onCancel) {
Icon(imageVector = Icons.Default.Stop, contentDescription = "Cancel")
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
}
}
Status.COMPLETED -> {
Button(onClick = onDelete) {
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
Text(text = " Delete", modifier = Modifier.padding(start = 6.dp))
}
}
else -> {
// for FAILED, CANCELLED, REMOVED etc.
Button(onClick = onRetry) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Retry"
)
Text(text = " Retry", modifier = Modifier.padding(start = 6.dp))
}
Button(onClick = onDelete) {
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
Text(text = " Delete", modifier = Modifier.padding(start = 6.dp))
}
}
}
}
}
}
}

View File

@@ -0,0 +1,229 @@
package com.acitelight.aether.view.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.model.VideoDownloadItemState
import com.acitelight.aether.viewModel.TransmissionScreenViewModel
import com.tonyodev.fetch2.Status
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import kotlin.math.abs
@Composable
fun VideoDownloadCardMini(
navigator: NavHostController,
viewModel: TransmissionScreenViewModel,
model: VideoDownloadItemState,
onPause: () -> Unit,
onResume: () -> Unit,
onCancel: () -> Unit,
onDelete: () -> Unit,
onRetry: () -> Unit
) {
val video = viewModel.modelToVideo(model)
val imageModel =
if (video == null)
ImageRequest.Builder(LocalContext.current)
.data(
File(
viewModel.context.getExternalFilesDir(null),
"videos/${model.klass}/${model.vid}/cover.jpg"
)
)
.memoryCacheKey("${model.klass}/${model.vid}/cover")
.diskCacheKey("${model.klass}/${model.vid}/cover")
.build()
else
ImageRequest.Builder(LocalContext.current)
.data(video.getCover(viewModel.apiClient))
.memoryCacheKey("${model.klass}/${model.vid}/cover")
.diskCacheKey("${model.klass}/${model.vid}/cover")
.build()
Card(
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.background(Color.Transparent)
.clickable(onClick = {
when (model.status) {
Status.COMPLETED -> viewModel.viewModelScope.launch(Dispatchers.IO)
{
viewModel.playStart(model, navigator)
}
Status.DOWNLOADING -> onPause()
Status.PAUSED -> onResume()
Status.ADDED, Status.FAILED, Status.CANCELLED -> onRetry()
else -> {}
}
})
.height(100.dp)
) {
Row(
modifier = Modifier.fillMaxSize()
)
{
Box(Modifier
.fillMaxHeight())
{
AsyncImage(
model = imageModel,
contentDescription = null,
modifier = Modifier
.height(100.dp)
.clip(RoundedCornerShape(8.dp))
.widthIn(max = 150.dp)
.background(Color.Black),
contentScale = ContentScale.Crop
)
IconButton(
onClick = onDelete,
Modifier
.padding(2.dp)
.size(24.dp)
.align(Alignment.TopStart)
.background(MaterialTheme.colorScheme.error, RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(4.dp))
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
}
Box(
Modifier
.fillMaxSize()
.padding(all = 4.dp)
.padding(end = 4.dp)
)
{
Text(
text = model.fileName,
lineHeight = 14.sp,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
modifier = Modifier.align(Alignment.TopEnd)
)
Column(Modifier.align(Alignment.BottomEnd)) {
Text(
modifier = Modifier.align(Alignment.End),
text = when (model.status) {
Status.COMPLETED -> "Completed"
Status.PAUSED, Status.QUEUED -> "Paused"
Status.DOWNLOADING -> "Downloading"
else -> "Error"
},
fontSize = 10.sp,
lineHeight = 11.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
Row(Modifier
.align(Alignment.End)
.padding(vertical = 2.dp)) {
Text(
modifier = Modifier,
text = "%.2f MB/%.2f MB".format(
model.downloadedBytes / (1024.0 * 1024.0),
model.totalBytes / (1024.0 * 1024.0)
),
fontSize = 10.sp,
lineHeight = 11.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
Spacer(Modifier.width(12.dp))
Text(
text = "${model.progress.coerceIn(0, 100)}%",
modifier = Modifier,
fontSize = 10.sp,
lineHeight = 11.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
BiliMiniSlider(
value = abs(model.progress).coerceIn(0, 100) / 100f,
modifier = Modifier
.height(6.dp)
.align(Alignment.End)
.fillMaxWidth(),
onValueChange = {
},
colors = when(model.status)
{
Status.DOWNLOADING, Status.QUEUED, Status.ADDED -> SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF),
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
)
Status.PAUSED -> SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF),
activeTrackColor = Color(0xFFFFA500),
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
)
Status.COMPLETED -> SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF),
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
)
else -> SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF),
activeTrackColor = MaterialTheme.colorScheme.error,
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
)
}
)
}
}
}
}
}

View File

@@ -0,0 +1,627 @@
package com.acitelight.aether.view.components
import android.app.Activity
import android.content.Context
import android.media.AudioManager
import android.view.View
import androidx.activity.compose.BackHandler
import androidx.annotation.OptIn
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Brightness4
import androidx.compose.material.icons.filled.FastForward
import androidx.compose.material.icons.filled.FullscreenExit
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewModelScope
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import com.acitelight.aether.setFullScreen
import com.acitelight.aether.view.pages.formatTime
import com.acitelight.aether.view.pages.moveBrit
import com.acitelight.aether.viewModel.VideoPlayerViewModel
import kotlinx.coroutines.launch
import kotlin.math.abs
@OptIn(UnstableApi::class)
@Composable
fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel) {
val colorScheme = MaterialTheme.colorScheme
val context = LocalContext.current
val activity = (context as? Activity)!!
val exoPlayer: ExoPlayer = videoPlayerViewModel.player!!
val name by videoPlayerViewModel.currentName
val id by videoPlayerViewModel.currentId
val listState = rememberLazyListState()
val videos = videoPlayerViewModel.videos
LaunchedEffect(id, videos) {
val targetIndex = videos.indexOfFirst { it.id == id }
if (targetIndex >= 0) {
listState.scrollToItem(targetIndex)
}
}
val audioManager = remember { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
var volFactor by remember {
mutableFloatStateOf(
audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume.toFloat()
)
}
fun setVolume(value: Int) {
audioManager.setStreamVolume(
AudioManager.STREAM_MUSIC,
value.coerceIn(0, maxVolume),
AudioManager.FLAG_PLAY_SOUND
)
}
BackHandler {
videoPlayerViewModel.isLandscape = false
}
val view = LocalView.current
DisposableEffect(Unit) {
setFullScreen(view, true)
onDispose {
setFullScreen(view, false)
}
}
Box(Modifier.fillMaxSize())
{
Box(
modifier = Modifier
.background(Color.Black)
.align(Alignment.Center)
)
{
Box(
Modifier
.fillMaxSize()
.pointerInput(videoPlayerViewModel) {
detectDragGestures(
onDragStart = { offset ->
if (videoPlayerViewModel.locked) return@detectDragGestures
if (offset.y > size.height * 0.9 || offset.y < size.height * 0.1)
videoPlayerViewModel.draggingPurpose = -3
// Set gesture protection for the bottom of the screen
// (Prevent conflicts with system gestures, such as dropdown status bar, bottom swipe up menu)
else if (offset.x < size.width / 2) {
videoPlayerViewModel.draggingPurpose = -1
} else {
videoPlayerViewModel.draggingPurpose = -2
}
},
onDragEnd = {
if (videoPlayerViewModel.isPlaying && videoPlayerViewModel.draggingPurpose == 0)
exoPlayer.play()
videoPlayerViewModel.draggingPurpose = -1
},
onDrag = { change, dragAmount ->
if (videoPlayerViewModel.locked) return@detectDragGestures
if (abs(dragAmount.x) > abs(dragAmount.y) &&
(videoPlayerViewModel.draggingPurpose == -1 || videoPlayerViewModel.draggingPurpose == -2)
) {
videoPlayerViewModel.draggingPurpose = 0
videoPlayerViewModel.planeVisibility = true
exoPlayer.pause()
} else if (videoPlayerViewModel.draggingPurpose == -1) videoPlayerViewModel.draggingPurpose =
1
else if (videoPlayerViewModel.draggingPurpose == -2) videoPlayerViewModel.draggingPurpose =
2
if (videoPlayerViewModel.draggingPurpose == 0) {
exoPlayer.seekTo((exoPlayer.currentPosition + dragAmount.x * 200.0f).toLong())
videoPlayerViewModel.playProcess =
exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat()
} else if (videoPlayerViewModel.draggingPurpose == 2) {
val cu = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
volFactor =
audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
.toFloat() / maxVolume.toFloat()
if (dragAmount.y < 0)
setVolume(cu + 1)
else if (dragAmount.y > 0)
setVolume(cu - 1)
} else if (videoPlayerViewModel.draggingPurpose == 1) {
moveBrit(dragAmount.y, activity, videoPlayerViewModel)
}
}
)
}
.pointerInput(videoPlayerViewModel) {
detectTapGestures(
onDoubleTap = {
if (videoPlayerViewModel.locked) return@detectTapGestures
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
onTap = {
if (videoPlayerViewModel.locked) return@detectTapGestures
if (videoPlayerViewModel.showPlaylist) {
videoPlayerViewModel.showPlaylist = false
return@detectTapGestures
}
videoPlayerViewModel.planeVisibility =
!videoPlayerViewModel.planeVisibility
},
onLongPress = {
if (videoPlayerViewModel.locked) return@detectTapGestures
videoPlayerViewModel.isLongPressing = true
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(3.0f)
},
onPress = { offset ->
val pressResult = tryAwaitRelease()
if (pressResult && videoPlayerViewModel.isLongPressing) {
videoPlayerViewModel.isLongPressing = false
exoPlayer.playbackParameters = exoPlayer.playbackParameters
.withSpeed(1.0f)
}
},
)
}) {
AndroidView(
factory = {
PlayerView(
it
).apply {
player = exoPlayer
useController = false
subtitleView?.let { sv ->
sv.visibility = View.GONE
}
}
},
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
)
}
AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 0,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Text(
text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${
formatTime(exoPlayer.duration)
}",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp),
fontSize = 18.sp
)
}
AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 2,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Row(
Modifier
.background(Color(0x88000000), RoundedCornerShape(18))
.width(200.dp)
)
{
Icon(
imageVector = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Vol",
tint = Color.White,
modifier = Modifier
.size(48.dp)
.padding(8.dp)
.align(Alignment.CenterVertically)
)
BiliMiniSlider(
value = volFactor,
onValueChange = {},
modifier = Modifier
.height(4.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
)
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.draggingPurpose == 1,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.Center)
)
{
Row(
Modifier
.background(Color(0x88000000), RoundedCornerShape(18))
.width(200.dp)
)
{
Icon(
imageVector = Icons.Default.Brightness4,
contentDescription = "Brightness",
tint = Color.White,
modifier = Modifier
.size(48.dp)
.padding(8.dp)
.align(Alignment.CenterVertically)
)
BiliMiniSlider(
value = videoPlayerViewModel.brit,
onValueChange = {},
modifier = Modifier
.height(4.dp)
.padding(horizontal = 8.dp)
.align(Alignment.CenterVertically)
)
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.isLongPressing,
enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }),
modifier = Modifier
.align(Alignment.TopCenter)
)
{
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 24.dp)
.background(Color(0x44000000), RoundedCornerShape(18))
)
{
Row {
Icon(
imageVector = Icons.Filled.FastForward,
contentDescription = "Fast Forward",
tint = Color.White,
modifier = Modifier
.size(36.dp)
.padding(4.dp)
.align(Alignment.CenterVertically)
)
Text(
text = "3X Speed...",
modifier = Modifier
.padding(4.dp)
.align(Alignment.CenterVertically),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFFFFFFFF)
)
}
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.planeVisibility && (!videoPlayerViewModel.locked),
enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }),
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
)
{
Row(
Modifier
.align(Alignment.TopStart)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.4f),
Color.Transparent,
)
)
)
)
{
IconButton(
onClick = {
videoPlayerViewModel.isLandscape = false
},
Modifier
.padding(top = 12.dp)
.padding(start = 46.dp)
.size(36.dp)
.align(Alignment.CenterVertically)
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
Text(
text = name,
fontWeight = FontWeight.Bold,
modifier = Modifier
.padding(top = 12.dp)
.align(Alignment.CenterVertically),
fontSize = 18.sp
)
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.planeVisibility && (!videoPlayerViewModel.locked),
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
)
{
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.4f)
)
)
)
.padding(horizontal = 36.dp)
) {
Text(
text = "${formatTime((exoPlayer.duration * videoPlayerViewModel.playProcess).toLong())}/${
formatTime(exoPlayer.duration)
}",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp),
fontSize = 12.sp
)
BiliStyleSlider(
value = videoPlayerViewModel.playProcess,
onValueChange = { value ->
exoPlayer.seekTo((exoPlayer.duration * value).toLong())
},
modifier = Modifier
.height(16.dp)
.fillMaxWidth()
.padding(bottom = 8.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 8.dp)
.align(Alignment.Start),
horizontalArrangement = Arrangement.SpaceBetween,
) {
IconButton(
onClick = {
videoPlayerViewModel.isPlaying = !videoPlayerViewModel.isPlaying
if (videoPlayerViewModel.isPlaying) exoPlayer.play() else exoPlayer.pause()
},
Modifier.size(42.dp)
) {
Icon(
imageVector = if (videoPlayerViewModel.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = "Play/Pause",
tint = Color.White,
modifier = Modifier.size(42.dp)
)
}
IconButton(
onClick = {
videoPlayerViewModel.viewModelScope.launch {
videoPlayerViewModel.startPlay(
videoPlayerViewModel.videos.getOrNull(videoPlayerViewModel.videos.indexOf(
videoPlayerViewModel.videos.first {
it.id == videoPlayerViewModel.currentId.value
}) + 1) ?: videoPlayerViewModel.videos.first()
)
}
},
Modifier.size(42.dp)
) {
Icon(
imageVector = Icons.Default.SkipNext,
contentDescription = "Next",
tint = Color.White,
modifier = Modifier.size(42.dp)
)
}
Spacer(Modifier.weight(1f))
IconButton(
onClick = {
videoPlayerViewModel.isLandscape = false
},
Modifier
.size(36.dp)
.align(Alignment.CenterVertically)
) {
Icon(
Icons.Default.FullscreenExit,
contentDescription = "Exit FullScreen",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
IconButton(
onClick = {
videoPlayerViewModel.showPlaylist = true
},
Modifier
.size(36.dp)
.align(Alignment.CenterVertically)
) {
Icon(
Icons.AutoMirrored.Filled.List,
contentDescription = "Playlist",
tint = Color.White,
modifier = Modifier.size(32.dp)
)
}
}
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.locked || videoPlayerViewModel.planeVisibility,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.CenterEnd)
)
{
Card(
modifier = Modifier.padding(4.dp),
colors = CardDefaults.cardColors(
containerColor = colorScheme.primary.copy(
if (videoPlayerViewModel.locked) 0.2f else 1f
)
),
onClick = {
videoPlayerViewModel.locked = !videoPlayerViewModel.locked
}) {
Icon(
imageVector = if (videoPlayerViewModel.locked) Icons.Default.LockOpen else Icons.Default.Lock,
contentDescription = "Lock",
tint = Color.White.copy(if (videoPlayerViewModel.locked) 0.2f else 1f),
modifier = Modifier
.size(36.dp)
.padding(6.dp)
)
}
}
AnimatedVisibility(
visible = videoPlayerViewModel.showPlaylist,
enter = slideInHorizontally(initialOffsetX = { full -> full }),
exit = slideOutHorizontally(targetOffsetX = { full -> full }),
modifier = Modifier.align(Alignment.CenterEnd)
)
{
Card(
Modifier
.fillMaxHeight()
.width(320.dp)
.align(Alignment.CenterEnd),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colorScheme.surface.copy(0.75f))
)
{
LazyColumn(state = listState, contentPadding = PaddingValues(vertical = 4.dp)) {
items(videoPlayerViewModel.videos) { item ->
MiniPlaylistCard(Modifier.padding(4.dp), video = item, imageLoader = videoPlayerViewModel.imageLoader!!,
selected = id == item.id, apiClient = videoPlayerViewModel.apiClient)
{
if (name == item.video.name)
return@MiniPlaylistCard
videoPlayerViewModel.viewModelScope.launch {
videoPlayerViewModel.startPlay(item)
}
}
}
}
}
}
SubtitleOverlay(
cues = videoPlayerViewModel.cues,
modifier = Modifier.matchParentSize()
)
}
}
}

View File

@@ -0,0 +1,277 @@
package com.acitelight.aether.view.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import com.acitelight.aether.Global
import com.acitelight.aether.view.pages.formatTime
import com.acitelight.aether.view.pages.toHex
import com.acitelight.aether.viewModel.VideoPlayerViewModel
@Composable
fun VideoPlayerPortal(
videoPlayerViewModel: VideoPlayerViewModel,
navController: NavHostController
) {
val colorScheme = MaterialTheme.colorScheme
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val minHeight = 42.dp
var coverAlpha by remember { mutableFloatStateOf(0.0f) }
var maxHeight = remember { screenHeight * 0.65f }
var posed = remember { false }
val dens = LocalDensity.current
val listState = rememberLazyListState()
var playerHeight by remember { mutableStateOf(screenHeight * 0.65f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val deltaY = available.y // px
val deltaDp = with(dens) { deltaY.toDp() }
val r = if (deltaY < 0 && playerHeight > minHeight) {
val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight)
val consumedDp = newHeight - playerHeight
playerHeight = newHeight
val consumedPx = with(dens) { consumedDp.toPx() }
Offset(0f, consumedPx)
} else if (deltaY > 0 && playerHeight < maxHeight && listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0) {
val newHeight = (playerHeight + deltaDp).coerceIn(minHeight, maxHeight)
val consumedDp = newHeight - playerHeight
playerHeight = newHeight
val consumedPx = with(dens) { consumedDp.toPx() }
Offset(0f, consumedPx)
} else {
Offset.Zero
}
val dh = playerHeight - minHeight
coverAlpha = (if (dh > 10.dp)
0f
else
(10.dp.value - dh.value) / 10.0f)
return r
}
}
}
val klass by videoPlayerViewModel.currentKlass
val id by videoPlayerViewModel.currentId
val name by videoPlayerViewModel.currentName
val duration by videoPlayerViewModel.currentDuration
Column(
Modifier
.nestedScroll(nestedScrollConnection)
.fillMaxHeight()
)
{
Box {
PortalCorePlayer(
Modifier
.heightIn(max = playerHeight)
.onGloballyPositioned { layoutCoordinates ->
if (!posed && videoPlayerViewModel.renderedFirst) {
maxHeight = with(dens) { layoutCoordinates.size.height.toDp() }
playerHeight = maxHeight
posed = true
}
},
videoPlayerViewModel = videoPlayerViewModel, coverAlpha
)
androidx.compose.animation.AnimatedVisibility(
visible = videoPlayerViewModel.locked || videoPlayerViewModel.planeVisibility,
enter = fadeIn(
initialAlpha = 0f,
),
exit = fadeOut(
targetAlpha = 0f
),
modifier = Modifier.align(Alignment.CenterEnd)
) {
Card(
modifier = Modifier.padding(4.dp),
colors = CardDefaults.cardColors(
containerColor = colorScheme.primary.copy(
if (videoPlayerViewModel.locked) 0.2f else 1f
)
),
onClick = {
videoPlayerViewModel.locked = !videoPlayerViewModel.locked
}) {
Icon(
imageVector = if (videoPlayerViewModel.locked) Icons.Default.LockOpen else Icons.Default.Lock,
contentDescription = "Lock",
tint = Color.White.copy(if (videoPlayerViewModel.locked) 0.2f else 1f),
modifier = Modifier
.size(36.dp)
.padding(6.dp)
)
}
}
}
Row()
{
TabRow(
selectedTabIndex = videoPlayerViewModel.tabIndex,
modifier = Modifier.height(38.dp)
) {
Tab(
selected = videoPlayerViewModel.tabIndex == 0,
onClick = { videoPlayerViewModel.tabIndex = 0 },
text = { Text(text = "Introduction", maxLines = 1) },
modifier = Modifier.height(38.dp)
)
Tab(
selected = videoPlayerViewModel.tabIndex == 1,
onClick = { videoPlayerViewModel.tabIndex = 1 },
text = { Text(text = "Comment", maxLines = 1) },
modifier = Modifier.height(38.dp)
)
}
}
LazyColumn(state = listState, modifier = Modifier.fillMaxWidth()) {
item {
Text(
modifier = Modifier
.align(Alignment.Start)
.padding(horizontal = 12.dp)
.padding(top = 12.dp),
text = name,
fontSize = 16.sp,
maxLines = 2,
fontWeight = FontWeight.Bold,
)
Row(
Modifier
.align(Alignment.Start)
.padding(horizontal = 4.dp)
.alpha(0.5f)
) {
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = "$klass.$id",
fontSize = 14.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
Text(
modifier = Modifier.padding(horizontal = 8.dp),
text = formatTime(duration),
fontSize = 14.sp,
maxLines = 1,
fontWeight = FontWeight.Bold,
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color)
if (videoPlayerViewModel.videos.size > 1) {
PlaylistPanel(
Modifier,
videoPlayerViewModel = videoPlayerViewModel
)
HorizontalDivider(
Modifier.padding(vertical = 8.dp),
1.dp,
DividerDefaults.color
)
}
HorizontalGallery(videoPlayerViewModel)
HorizontalDivider(Modifier.padding(vertical = 8.dp), 1.dp, DividerDefaults.color)
for (i in Global.sameClassVideos ?: listOf()) {
if (i.id == id) continue
MiniVideoCard(
modifier = Modifier
.padding(horizontal = 12.dp),
i,
apiClient = videoPlayerViewModel.apiClient,
imageLoader = videoPlayerViewModel.imageLoader!!
) {
videoPlayerViewModel.isPlaying = false
videoPlayerViewModel.player?.pause()
val playList = mutableListOf<String>()
val fv =
videoPlayerViewModel.videoLibrary.classesMap.map { it.value }.flatten()
val group =
fv.filter { it.klass == i.klass && it.video.group == i.video.group }
for (i in group) {
playList.add("${i.klass}/${i.id}")
}
val route = "video_player_route/${(playList.joinToString(",") + "|${i.id}").toHex()}"
navController.navigate(route)
}
HorizontalDivider(
Modifier
.padding(vertical = 8.dp)
.alpha(0.25f),
1.dp,
DividerDefaults.color
)
}
}
}
}
}

View File

@@ -0,0 +1,438 @@
package com.acitelight.aether.view.pages
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.staggeredgrid.LazyHorizontalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.model.BookMark
import com.acitelight.aether.model.Comic
import com.acitelight.aether.setFullScreen
import com.acitelight.aether.view.components.BiliMiniSlider
import com.acitelight.aether.viewModel.ComicGridViewModel
@Composable
fun ComicGridView(
comicId: String,
navController: NavHostController,
comicGridViewModel: ComicGridViewModel = hiltViewModel<ComicGridViewModel>()
) {
comicGridViewModel.resolve(comicId.hexToString())
comicGridViewModel.updateProcess(comicId.hexToString()) {}
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val screenWidth = configuration.screenWidthDp.dp
val record by comicGridViewModel.record
val comic by comicGridViewModel.comic
val view = LocalView.current
DisposableEffect(Unit) {
setFullScreen(view, true)
onDispose {
val nextRoute = navController.currentBackStackEntry?.destination?.route
if (nextRoute?.startsWith("comic_page_route") != true) {
setFullScreen(view, false)
}
}
}
LaunchedEffect(comicGridViewModel) {
comicGridViewModel.coverHeight = screenHeight * 0.3f
if(comicGridViewModel.maxHeight == 0.dp)
comicGridViewModel.maxHeight = screenHeight * 0.8f
}
val dens = LocalDensity.current
val listState = rememberLazyListState()
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val deltaY = available.y // px
val deltaDp = with(dens) { deltaY.toDp() }
val r = if (deltaY < 0 && comicGridViewModel.coverHeight > 0.dp) {
val newHeight = (comicGridViewModel.coverHeight + deltaDp).coerceIn(0.dp, comicGridViewModel.maxHeight)
val consumedDp = newHeight - comicGridViewModel.coverHeight
comicGridViewModel.coverHeight = newHeight
val consumedPx = with(dens) { consumedDp.toPx() }
Offset(0f, consumedPx)
} else if (
deltaY > 0
&& comicGridViewModel.coverHeight < comicGridViewModel.maxHeight
&& listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
) {
val newHeight = (comicGridViewModel.coverHeight + deltaDp).coerceIn(0.dp, comicGridViewModel.maxHeight)
val consumedDp = newHeight - comicGridViewModel.coverHeight
comicGridViewModel.coverHeight = newHeight
val consumedPx = with(dens) { consumedDp.toPx() }
Offset(0f, consumedPx)
} else {
Offset.Zero
}
return r
}
}
}
if (comic != null) {
val comic = comic!!
val pagerState = rememberPagerState(
initialPage = 0,
pageCount = { comic.comic.bookmarks.size })
Column(Modifier
.nestedScroll(nestedScrollConnection).fillMaxSize()) {
Box(Modifier
.fillMaxWidth()
.height(comicGridViewModel.coverHeight))
{
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize()
)
{ page ->
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(comic.getPage(comic.comic.bookmarks[page].page, comicGridViewModel.apiClient))
.memoryCacheKey("${comic.id}/${comic.comic.bookmarks[page].page}")
.diskCacheKey("${comic.id}/${comic.comic.bookmarks[page].page}")
.build(),
contentDescription = null,
imageLoader = comicGridViewModel.imageLoader!!,
modifier = Modifier
.fillMaxSize(),
contentScale = ContentScale.FillWidth,
onSuccess = { success ->
val drawable = success.result.image
val width = drawable.width
val height = drawable.height
val aspectRatio = width.toFloat() / height.toFloat()
comicGridViewModel.maxHeight = min(screenWidth / aspectRatio, screenHeight * 0.8f)
if(comicGridViewModel.coverHeight > comicGridViewModel.maxHeight)
comicGridViewModel.coverHeight = comicGridViewModel.maxHeight
},
)
}
Box(modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.height(50.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.5f),
)
)
)
)
BiliMiniSlider(
value = (pagerState.currentPage + 1) / pagerState.pageCount.toFloat(),
modifier = Modifier
.height(6.dp)
.width(100.dp)
.align(Alignment.BottomCenter)
.fillMaxWidth(),
onValueChange = {
}
)
}
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(top = 6.dp)
.clip(RoundedCornerShape(6.dp))
)
{
item()
{
Text(
text = comic.comic.comic_name,
fontSize = 18.sp,
lineHeight = 22.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier.padding(horizontal = 16.dp).padding(top = 16.dp).padding(bottom = 4.dp)
)
FlowRow(
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 4.dp)
)
{
comic.comic.tags.take(15).forEach()
{
ic ->
Card(
Modifier.padding(1.dp),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = ic,
fontSize = 10.sp,
lineHeight = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
modifier = Modifier
.padding(4.dp)
)
}
}
}
Box(Modifier.fillMaxWidth())
{
Text(
text = "Author: ${comic.comic.author} \n${comic.comic.list.size} Pages",
fontSize = 11.sp,
lineHeight = 15.sp,
maxLines = 3,
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 4.dp).align(Alignment.CenterStart)
)
Button(onClick = {
comicGridViewModel.updateProcess(comicId.hexToString())
{
if (record != null) {
val route = "comic_page_route/${comic.id.toHex()}/${
record!!.position
}"
navController.navigate(route)
} else {
val route = "comic_page_route/${comic.id.toHex()}/${0}"
navController.navigate(route)
}
}
}, modifier = Modifier.align(Alignment.CenterEnd))
{
Text(text = "Continue", fontSize = 16.sp)
}
}
HorizontalDivider(Modifier.padding(horizontal = 12.dp).padding(bottom = 4.dp), thickness = 1.5.dp)
}
items(comicGridViewModel.chapterList)
{ c ->
ChapterCard(comic, navController, c, comicGridViewModel)
HorizontalDivider(Modifier.padding(horizontal = 26.dp), thickness = 1.5.dp)
}
}
/*
Card(
Modifier
.padding(horizontal = 16.dp)
.padding(top = 6.dp)
.padding(bottom = 20.dp)
.height(42.dp)
.clickable {
comicGridViewModel.updateProcess(comicId.hexToString())
{
if (record != null) {
val route = "comic_page_route/${comic.id.toHex()}/${
record!!.position
}"
navController.navigate(route)
} else {
val route = "comic_page_route/${comic.id.toHex()}/${0}"
navController.navigate(route)
}
}
},
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
shape = RoundedCornerShape(12.dp)
)
{
Box(Modifier.fillMaxSize()) {
Row(
Modifier
.fillMaxWidth()
.align(Alignment.Center)
.padding(horizontal = 8.dp)
) {
if (record != null) {
val k = comic.getPageChapterIndex(record!!.position)
Text(
text = "Last Read Position: ${k.first.name} ${k.second}/${
comic.getChapterLength(
k.first.page
)
}",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier
.padding(4.dp)
.weight(1f)
)
} else {
Text(
text = "Read from scratch",
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier
.padding(4.dp)
.weight(1f)
)
}
}
}
}
*/
}
}
}
@Composable
fun ChapterCard(
comic: Comic,
navController: NavHostController,
chapter: BookMark,
comicGridViewModel: ComicGridViewModel = hiltViewModel<ComicGridViewModel>()
) {
val c = chapter
val iv = comic.getPageIndex(c.page)
val r = comic.comic.list.subList(iv, iv + comic.getChapterLength(c.page))
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface.copy(0.65f)),
shape = RoundedCornerShape(6.dp),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.padding(horizontal = 16.dp)
.padding(vertical = 6.dp),
onClick = {
val route = "comic_page_route/${comic.id.toHex()}/${comic.getPageIndex(chapter.page)}"
navController.navigate(route)
}
) {
Column(Modifier.fillMaxWidth())
{
Text(
text = chapter.name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
lineHeight = 18.sp,
modifier = Modifier
.padding(horizontal = 8.dp).padding(vertical = 4.dp)
.background(Color.Transparent)
)
Text(
text = "${comic.getChapterLength(chapter.page)} Pages",
fontSize = 14.sp,
lineHeight = 16.sp,
maxLines = 1,
modifier = Modifier
.padding(horizontal = 8.dp)
.background(Color.Transparent)
)
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp).padding(vertical = 4.dp)
) {
items(r)
{ r ->
Card(
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.height(120.dp)
.padding(horizontal = 2.dp),
onClick = {
val route =
"comic_page_route/${comic.id.toHex()}/${comic.getPageIndex(r)}"
navController.navigate(route)
}
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(comic.getPage(r, comicGridViewModel.apiClient))
.memoryCacheKey("${comic.id}/${r}")
.diskCacheKey("${comic.id}/${r}")
.build(),
contentDescription = null,
imageLoader = comicGridViewModel.imageLoader!!,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Fit,
)
}
}
}
}
}
}

View File

@@ -0,0 +1,355 @@
package com.acitelight.aether.view.pages
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmarks
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.model.BookMark
import com.acitelight.aether.setFullScreen
import com.acitelight.aether.view.components.BiliMiniSlider
import com.acitelight.aether.view.components.BookmarkPop
import com.acitelight.aether.viewModel.ComicPageViewModel
import kotlinx.coroutines.launch
@Composable
fun ComicPageView(
comicId: String,
page: String,
navController: NavHostController,
comicPageViewModel: ComicPageViewModel = hiltViewModel<ComicPageViewModel>()
) {
val colorScheme = MaterialTheme.colorScheme
comicPageViewModel.Resolve(comicId.hexToString(), page.toInt())
val title by comicPageViewModel.title
val pagerState = rememberPagerState(
initialPage = page.toInt(),
pageCount = { comicPageViewModel.pageList.size })
var showPlane by comicPageViewModel.showPlane
var showBookMarkPop by remember { mutableStateOf(false) }
comicPageViewModel.updateProcess(pagerState.currentPage)
val comic by comicPageViewModel.comic
val view = LocalView.current
DisposableEffect(Unit) {
setFullScreen(view, true)
onDispose {
}
}
comic?.let {
Box()
{
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.align(Alignment.Center)
.background(Color.Black)
.pointerInput(Unit) {
detectTapGestures(
onTap = {
showPlane = !showPlane
if (showPlane) {
comicPageViewModel.viewModelScope.launch {
comicPageViewModel.listState?.scrollToItem(index = pagerState.currentPage)
}
}
}
)
}
) { page ->
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(it.getPage(page, comicPageViewModel.apiClient))
.memoryCacheKey("${it.id}/${page}")
.diskCacheKey("${it.id}/${page}")
.build(),
contentDescription = null,
imageLoader = comicPageViewModel.imageLoader!!,
modifier = Modifier
.padding(8.dp)
.fillMaxSize(),
contentScale = ContentScale.Fit,
)
}
AnimatedVisibility(
visible = showPlane,
enter = slideInVertically(initialOffsetY = { fullHeight -> -fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> -fullHeight }),
modifier = Modifier
.align(Alignment.TopCenter)
) {
Column(Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.9f),
Color.Transparent,
)
)
))
{
Row(modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp).padding(top = 16.dp))
{
Text(
text = title,
fontSize = 16.sp,
lineHeight = 19.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
color = Color.White,
modifier = Modifier
.padding(8.dp)
.padding(horizontal = 10.dp)
.weight(1f)
.align(Alignment.CenterVertically)
)
Text(
text = "${pagerState.currentPage + 1}/${pagerState.pageCount}",
fontSize = 16.sp,
lineHeight = 19.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
color = Color.White,
modifier = Modifier
.padding(8.dp)
.widthIn(min = 60.dp)
.align(Alignment.CenterVertically)
)
}
Box(Modifier.fillMaxWidth()
.padding(horizontal = 16.dp))
{
Row {
val k = it.getPageChapterIndex(pagerState.currentPage)
Text(
text = k.first.name,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
color = Color.White,
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,
color = Color.White,
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.surface),
shape = RoundedCornerShape(12.dp)
)
{
Box(Modifier.clickable {
showBookMarkPop = true
}) {
Icon(
Icons.Filled.Bookmarks,
modifier = Modifier
.padding(8.dp),
contentDescription = "Bookmark"
)
}
}
}
Spacer(Modifier.height(64.dp))
}
}
AnimatedVisibility(
visible = showPlane,
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }),
exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }),
modifier = Modifier
.align(Alignment.BottomCenter)
)
{
val k = it.getPageChapterIndex(pagerState.currentPage)
Column(Modifier
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.9f),
)
)
)) {
Spacer(Modifier.height(42.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(5.dp),
state = comicPageViewModel.listState!!, modifier = Modifier
.fillMaxWidth()
.padding(bottom = 1.dp)
.padding(horizontal = 12.dp)
.height(180.dp)
)
{
items(comicPageViewModel.pageList.size)
{ r ->
Card(
colors = CardDefaults.cardColors(containerColor = colorScheme.primary.copy(0.8f)),
shape = RoundedCornerShape(8.dp),
modifier = Modifier
.fillMaxHeight()
.wrapContentHeight()
.padding(vertical = 8.dp),
onClick = {
pagerState.requestScrollToPage(page = r)
}
) {
Box(Modifier.padding(0.dp))
{
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(it.getPage(r, comicPageViewModel.apiClient))
.memoryCacheKey("${it.id}/${r}")
.diskCacheKey("${it.id}/${r}")
.build(),
contentDescription = null,
imageLoader = comicPageViewModel.imageLoader!!,
modifier = Modifier
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.align(Alignment.Center),
contentScale = ContentScale.Fit,
)
val k = it.getPageChapterIndex(r)
Box(
Modifier
.align(Alignment.TopEnd)
.padding(6.dp)
.background(
Color.Black.copy(alpha = 0.65f),
shape = RoundedCornerShape(12.dp)
)
)
{
Row {
Text(
text = "${k.second}/${it.getChapterLength(k.first.page)}",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1,
modifier = Modifier
.padding(2.dp)
.align(Alignment.CenterVertically)
)
}
}
}
}
}
}
BiliMiniSlider(
value = (k.second.toInt()) / it.getChapterLength(k.first.page).toFloat(),
modifier = Modifier
.height(6.dp)
.fillMaxWidth().padding(horizontal = 24.dp)
.fillMaxWidth(),
onValueChange = {
}
)
Spacer(Modifier.height(24.dp))
}
}
}
}
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())
}
});
}
}

View File

@@ -0,0 +1,339 @@
package com.acitelight.aether.view.pages
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import com.acitelight.aether.view.components.ComicCard
import com.acitelight.aether.viewModel.ComicScreenViewModel
@Composable
fun VariableGrid(
modifier: Modifier = Modifier,
rowHeight: Dp,
horizontalSpacing: Dp = 4.dp,
verticalSpacing: Dp = 4.dp,
content: @Composable () -> Unit
) {
val scrollState = rememberScrollState()
Layout(
modifier = modifier
.verticalScroll(scrollState),
content = content
) { measurables, constraints ->
val rowHeightPx = rowHeight.roundToPx()
val hSpacePx = horizontalSpacing.roundToPx()
val vSpacePx = verticalSpacing.roundToPx()
val placeables = measurables.map { measurable ->
measurable.measure(
constraints.copy(
minWidth = 0,
minHeight = rowHeightPx,
maxHeight = rowHeightPx
)
)
}
val rows = mutableListOf<List<Placeable>>()
var currentRow = mutableListOf<Placeable>()
var currentWidth = 0
val maxWidth = constraints.maxWidth
for (placeable in placeables) {
if (currentRow.isNotEmpty() && currentWidth + placeable.width + hSpacePx > maxWidth) {
rows.add(currentRow)
currentRow = mutableListOf()
currentWidth = 0
}
currentRow.add(placeable)
currentWidth += placeable.width + hSpacePx
}
if (currentRow.isNotEmpty()) {
rows.add(currentRow)
}
val layoutHeight = if (rows.isEmpty()) {
0
} else {
rows.size * rowHeightPx + (rows.size - 1) * vSpacePx
}
layout(
width = constraints.maxWidth.coerceAtLeast(constraints.minWidth),
height = layoutHeight.coerceAtLeast(constraints.minHeight)
) {
var y = 0
for (row in rows) {
var x = 0
for (placeable in row) {
placeable.placeRelative(x, y)
x += placeable.width + hSpacePx
}
y += rowHeightPx + vSpacePx
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ComicScreen(
navController: NavHostController,
comicScreenViewModel: ComicScreenViewModel = hiltViewModel<ComicScreenViewModel>()
) {
val included = comicScreenViewModel.included
val state = rememberLazyStaggeredGridState()
val colorScheme = MaterialTheme.colorScheme
var searchFilter by comicScreenViewModel.searchFilter
var isTagsVisible by remember { mutableStateOf(false) }
var sortType by remember { mutableIntStateOf(0) }
Column(
modifier = Modifier.animateContentSize()
) {
Row(
Modifier
.padding(4.dp)
.align(Alignment.CenterHorizontally)
)
{
Text(
text = "Comics",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.align(Alignment.CenterVertically)
)
Spacer(Modifier.weight(1f))
Row(
modifier = Modifier
.align(Alignment.CenterVertically)
.height(36.dp)
.widthIn(max = 240.dp)
.background(colorScheme.surface, RoundedCornerShape(8.dp))
.padding(horizontal = 6.dp)
) {
Icon(
modifier = Modifier
.size(30.dp)
.align(Alignment.CenterVertically),
imageVector = Icons.Default.Search,
contentDescription = "Catalogue"
)
Spacer(Modifier.width(4.dp))
BasicTextField(
value = searchFilter,
onValueChange = { searchFilter = it },
textStyle = LocalTextStyle.current.copy(
fontSize = 18.sp,
color = Color.White,
textAlign = TextAlign.Start
),
singleLine = true,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
Row {
Text(
text = "Sorted by: ",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
modifier = Modifier.padding(horizontal = 6.dp).align(Alignment.CenterVertically)
)
RadioButton(
selected = (sortType == 0),
onClick = { sortType = 0 },
modifier = Modifier.align(Alignment.CenterVertically).size(24.dp)
)
Text(
text = "Id",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
modifier = Modifier.align(Alignment.CenterVertically).padding(3.dp)
)
Spacer(modifier = Modifier.width(12.dp))
RadioButton(
selected = (sortType == 1),
onClick = { sortType = 1 },
modifier = Modifier.align(Alignment.CenterVertically).size(24.dp)
)
Text(
text = "Name",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
modifier = Modifier.align(Alignment.CenterVertically).padding(3.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(Modifier.weight(1f))
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colorScheme.surface),
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(horizontal = 4.dp)
.padding(vertical = 4.dp)
.height(32.dp)
.width(64.dp),
onClick = {
isTagsVisible = !isTagsVisible
})
{
Row(Modifier.fillMaxSize())
{
Text(text = "Tags", fontWeight = FontWeight.Bold, fontSize = 16.sp, modifier = Modifier.align(Alignment.CenterVertically).padding(start = 5.dp))
ExposedDropdownMenuDefaults.TrailingIcon(expanded = isTagsVisible, modifier = Modifier.align(Alignment.CenterVertically).padding(end = 5.dp))
}
}
}
HorizontalDivider(Modifier.padding(1.dp), thickness = 1.5.dp)
AnimatedVisibility(
visible = isTagsVisible,
enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(),
exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut()
) {
Column {
VariableGrid(
modifier = Modifier
.heightIn(max = 80.dp)
.padding(4.dp),
rowHeight = 30.dp
)
{
for (i in comicScreenViewModel.tags) {
Box(
Modifier
.background(
if (included.contains(i)) Color.Green.copy(alpha = 0.65f) else colorScheme.surface,
shape = RoundedCornerShape(4.dp)
)
.height(32.dp)
.widthIn(max = 72.dp)
.clickable {
if (included.contains(i))
included.remove(i)
else
included.add(i)
}
) {
Text(
text = i,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
maxLines = 1,
modifier = Modifier
.padding(2.dp)
.align(Alignment.Center)
)
}
}
}
HorizontalDivider(Modifier.padding(1.dp), thickness = 1.5.dp)
}
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(120.dp),
contentPadding = PaddingValues(4.dp),
verticalItemSpacing = 6.dp,
horizontalArrangement = Arrangement.spacedBy(4.dp),
state = state,
modifier = Modifier.fillMaxSize()
) {
items(
items = comicScreenViewModel.comics
.filter { searchFilter.isEmpty() || searchFilter in it.comic.comic_name }
.filter { x ->
included.all { y -> y in x.comic.tags } || included.isEmpty()
}
.sortedByDescending {
when(sortType)
{
0 -> it.id.toInt().toString().padStart(10, '0')
1 -> it.comic.comic_name
else -> it.id
}
},
key = { it.id }
) { comic ->
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
ComicCard(comic, navController, comicScreenViewModel)
}
}
}
}
}

View File

@@ -0,0 +1,215 @@
package com.acitelight.aether.view.pages
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.Global.updateRelate
import com.acitelight.aether.model.Comic
import com.acitelight.aether.view.components.MiniVideoCard
import com.acitelight.aether.viewModel.HomeScreenViewModel
@Composable
fun HomeScreen(
homeScreenViewModel: HomeScreenViewModel = hiltViewModel<HomeScreenViewModel>(),
navController: NavHostController
) {
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
) { p ->
if (p == 0) {
Column(Modifier.fillMaxHeight()) {
Text(
text = "Videos",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.padding(8.dp)
.align(Alignment.Start)
)
HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color)
LazyColumn(modifier = Modifier.fillMaxWidth())
{
items(homeScreenViewModel.recentManager.recentVideo)
{ i ->
MiniVideoCard(
modifier = Modifier
.padding(horizontal = 12.dp),
i,
apiClient = homeScreenViewModel.apiClient,
imageLoader = homeScreenViewModel.imageLoader!!
)
{
updateRelate(homeScreenViewModel.recentManager.recentVideo, i)
val playList = mutableListOf<String>()
val fv = homeScreenViewModel.videoLibrary.classesMap.map { it.value }
.flatten()
val group =
fv.filter { it.klass == i.klass && it.video.group == i.video.group && it.video.group != "null" }
for (ix in group) {
playList.add("${ix.klass}/${ix.id}")
}
if(!playList.contains("${i.klass}/${i.id}"))
playList.add("${i.klass}/${i.id}")
val route =
"video_player_route/${(playList.joinToString(",") + "|${i.id}").toHex()}"
navController.navigate(route)
}
HorizontalDivider(
Modifier
.padding(vertical = 8.dp)
.alpha(0.4f),
1.dp,
DividerDefaults.color
)
}
}
}
} else {
Column(Modifier.fillMaxHeight()) {
Text(
text = "Comics",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.padding(8.dp)
.align(Alignment.Start)
)
HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color)
LazyVerticalGrid(
columns = GridCells.Adaptive(128.dp),
contentPadding = PaddingValues(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
)
{
items(homeScreenViewModel.recentManager.recentComic)
{ comic ->
ComicCardRecent(comic, navController, homeScreenViewModel)
}
}
}
}
}
}
@Composable
fun ComicCardRecent(
comic: Comic,
navController: NavHostController,
homeScreenViewModel: HomeScreenViewModel
) {
Card(
shape = RoundedCornerShape(6.dp),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
onClick = {
val route = "comic_grid_route/${comic.id.toHex()}"
navController.navigate(route)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(comic.getPage(0, homeScreenViewModel.apiClient))
.memoryCacheKey("${comic.id}/${0}")
.diskCacheKey("${comic.id}/${0}")
.build(),
contentDescription = null,
imageLoader = homeScreenViewModel.imageLoader!!,
modifier = Modifier
.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Box(
Modifier
.fillMaxWidth()
.height(24.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.45f)
)
)
)
.align(Alignment.BottomCenter)
)
{
Text(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(2.dp),
fontSize = 12.sp,
text = "${comic.comic.list.size} Pages",
fontWeight = FontWeight.Bold,
color = Color.White
)
}
}
Text(
text = comic.comic.comic_name,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
modifier = Modifier
.padding(8.dp)
.background(Color.Transparent)
.heightIn(48.dp)
)
}
}
}

View File

@@ -0,0 +1,13 @@
package com.acitelight.aether.view.pages
import androidx.compose.runtime.Composable
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.acitelight.aether.viewModel.LiveScreenViewModel
@Composable
fun LiveScreen(
liveScreenViewModel: LiveScreenViewModel = hiltViewModel<LiveScreenViewModel>()
)
{
}

View File

@@ -1,22 +1,24 @@
package com.acitelight.aether.view package com.acitelight.aether.view.pages
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.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
import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Textsms
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.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
@@ -27,21 +29,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.acitelight.aether.viewModel.MeScreenViewModel import com.acitelight.aether.viewModel.MeScreenViewModel
@Composable @Composable
fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) { fun MeScreen(meScreenViewModel: MeScreenViewModel = hiltViewModel<MeScreenViewModel>()) {
val context = LocalContext.current var username by meScreenViewModel.username
var username by meScreenViewModel.username; var privateKey by meScreenViewModel.privateKey
var privateKey by meScreenViewModel.privateKey;
var url by meScreenViewModel.url var url by meScreenViewModel.url
var cert by meScreenViewModel.cert var cert by meScreenViewModel.cert
var pak by meScreenViewModel.pak
val uss by meScreenViewModel.uss.collectAsState(initial = false)
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
@@ -49,7 +52,8 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) {
.padding(8.dp), .padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top verticalArrangement = Arrangement.Top
) { )
{
// Card component for a clean, contained UI block // Card component for a clean, contained UI block
item{ item{
Card( Card(
@@ -104,7 +108,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 +138,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 +152,98 @@ 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.weight(0.5f).padding(8.dp)
) {
Text("Save")
}
}
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
{
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Toolbox",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.padding(bottom = 16.dp)
.align(Alignment.Start)
)
Spacer(modifier = Modifier.width(8.dp))
OutlinedTextField(
value = pak,
onValueChange = { pak = it },
label = { Text("Packet") },
leadingIcon = {
Icon(Icons.Default.Textsms, contentDescription = "Packet")
}, },
singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { )
Text("Save")
Spacer(modifier = Modifier.height(8.dp))
Row{
Button(
onClick = {
meScreenViewModel.sendPacket(pak)
},
modifier = Modifier.weight(0.5f).padding(8.dp)
) {
Text("Send")
}
} }
} }
} }

View File

@@ -0,0 +1,138 @@
package com.acitelight.aether.view.pages
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavHostController
import com.acitelight.aether.model.VideoDownloadItemState
import com.acitelight.aether.view.components.BiliMiniSlider
import com.acitelight.aether.view.components.VideoDownloadCardMini
import com.acitelight.aether.viewModel.TransmissionScreenViewModel
import com.tonyodev.fetch2.Status
import kotlin.collections.sortedWith
@Composable
fun TransmissionScreen(
navigator: NavHostController,
transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel<TransmissionScreenViewModel>()
) {
val downloads = transmissionScreenViewModel.downloads
Column()
{
Text(
text = "Video Tasks",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.padding(8.dp)
.align(Alignment.Start)
)
Text(
text = "All: ${downloads.count { it.type == "main" }}",
modifier = Modifier
.padding(horizontal = 8.dp)
.align(Alignment.Start),
fontSize = 12.sp,
lineHeight = 13.sp,
maxLines = 1
)
Text(
text = "Completed: ${downloads.count { it.type == "main" && it.status == Status.COMPLETED }}",
modifier = Modifier
.padding(horizontal = 8.dp)
.align(Alignment.Start),
fontSize = 12.sp,
lineHeight = 13.sp,
maxLines = 1
)
val downloading = downloads.filter { it.status == Status.DOWNLOADING }
BiliMiniSlider(
value = if (downloading.sumOf { it.totalBytes } == 0L) 1f else downloading.sumOf { it.downloadedBytes } / downloading.sumOf { it.totalBytes }
.toFloat(),
modifier = Modifier
.height(6.dp)
.align(Alignment.End)
.fillMaxWidth(),
onValueChange = {
}
)
HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color)
LazyColumn(
modifier = Modifier.fillMaxWidth()
)
{
items(
downloads
.filter { it.type == "main" }
.sortedWith(compareBy(naturalOrder()) { it.fileName })
.sortedBy { it.status == Status.COMPLETED }, key = { it.id })
{ item ->
VideoDownloadCardMini(
navigator = navigator,
viewModel = transmissionScreenViewModel,
model = item,
onPause = {
for (i in downloadToGroup(
item,
downloads
)) transmissionScreenViewModel.pause(i.id)
},
onResume = {
for (i in downloadToGroup(
item,
downloads
)) transmissionScreenViewModel.resume(i.id)
},
onCancel = {
for (i in downloadToGroup(
item,
downloads
)) transmissionScreenViewModel.delete(i.id)
},
onDelete = {
for (i in downloadToGroup(
item,
downloads
)) transmissionScreenViewModel.delete(i.id)
},
onRetry = {
for (i in downloadToGroup(
item,
downloads
)) transmissionScreenViewModel.retry(i.id)
}
)
HorizontalDivider(
Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
2.dp,
DividerDefaults.color
)
}
}
}
}
fun downloadToGroup(
i: VideoDownloadItemState,
downloads: List<VideoDownloadItemState>
): List<VideoDownloadItemState> {
return downloads.filter { it.vid == i.vid && it.klass == i.klass }
}

View File

@@ -0,0 +1,68 @@
package com.acitelight.aether.view.pages
import android.app.Activity
import android.content.pm.ActivityInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavHostController
import com.acitelight.aether.viewModel.VideoPlayerViewModel
import androidx.compose.runtime.DisposableEffect
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.acitelight.aether.view.components.VideoPlayerLandscape
import com.acitelight.aether.view.components.VideoPlayerPortal
import kotlin.math.pow
fun formatTime(ms: Long): String {
if (ms <= 0) return "00:00:00"
val totalSeconds = ms / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d:%02d", hours, minutes, seconds)
}
fun moveBrit(db: Float, activity: Activity, videoPlayerViewModel: VideoPlayerViewModel) {
val attr = activity.window.attributes
val britUi = (videoPlayerViewModel.brit - db * 0.002f).coerceIn(0f, 1f)
videoPlayerViewModel.brit = britUi
val gamma = 2.2f
val britSystem = britUi.pow(gamma).coerceIn(0.001f, 1f)
attr.screenBrightness = britSystem
activity.window.attributes = attr
}
@Composable
fun VideoPlayer(
videoPlayerViewModel: VideoPlayerViewModel = hiltViewModel<VideoPlayerViewModel>(),
videoId: String,
navController: NavHostController
) {
val context = LocalContext.current
val activity = (context as? Activity)!!
DisposableEffect(Unit) {
onDispose {
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
videoPlayerViewModel.init(videoId)
activity.requestedOrientation =
if(videoPlayerViewModel.isLandscape)
ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
else
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
if (videoPlayerViewModel.startPlaying) {
if (videoPlayerViewModel.isLandscape) {
VideoPlayerLandscape(videoPlayerViewModel)
} else {
VideoPlayerPortal(videoPlayerViewModel, navController)
}
}
}

View File

@@ -0,0 +1,306 @@
package com.acitelight.aether.view.pages
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.acitelight.aether.model.Video
import com.acitelight.aether.viewModel.VideoScreenViewModel
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import coil3.request.ImageRequest
import com.acitelight.aether.CardPage
import com.acitelight.aether.Global.updateRelate
import com.acitelight.aether.view.components.VideoCard
import kotlinx.coroutines.launch
import java.nio.charset.Charset
import kotlin.collections.sortedWith
fun videoToView(v: List<Video>): Map<String?, List<Video>>
{
return v.map { if(it.video.group != null) it else Video(id=it.id, isLocal = it.isLocal, localBase = it.localBase,
klass = it.klass, video = it.video.copy(group = it.video.name)) }.groupBy { it.video.group }
}
fun String.toHex(): String {
return this.toByteArray().joinToString("") { "%02x".format(it) }
}
fun String.hexToString(charset: Charset = Charsets.UTF_8): String {
require(length % 2 == 0) { "Hex string must have even length" }
val bytes = ByteArray(length / 2)
for (i in bytes.indices) {
val hexByte = substring(i * 2, i * 2 + 2)
bytes[i] = hexByte.toInt(16).toByte()
}
return String(bytes, charset)
}
@Composable
fun VideoScreen(
videoScreenViewModel: VideoScreenViewModel = hiltViewModel<VideoScreenViewModel>(),
navController: NavHostController
) {
val state = rememberLazyStaggeredGridState()
val colorScheme = MaterialTheme.colorScheme
val tabIndex by videoScreenViewModel.tabIndex
var menuVisibility by videoScreenViewModel.menuVisibility
var searchFilter by videoScreenViewModel.searchFilter
var doneInit by videoScreenViewModel.doneInit
val vb = videoToView(videoScreenViewModel.videoLibrary.classesMap.getOrDefault(
videoScreenViewModel.videoLibrary.classes.getOrNull(
tabIndex
), listOf()
).filter { it.video.name.contains(searchFilter) }).filter { it.key != null }
.map{ i -> Pair(i.key!!, i.value.sortedWith(compareBy(naturalOrder()) { it.video.name }) ) }
.toList()
if (doneInit)
CardPage(title = "Videos") {
Box(Modifier.fillMaxSize())
{
Column(
modifier = Modifier.fillMaxSize()
) {
Text(
text = "Videos",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.padding(horizontal = 8.dp)
.align(Alignment.Start)
)
// TopRow(videoScreenViewModel);
Row(Modifier.padding(bottom = 4.dp).padding(start = 8.dp))
{
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(horizontal = 1.dp)
.size(36.dp),
onClick = {
menuVisibility = !menuVisibility
})
{
Box(Modifier.fillMaxSize())
{
Icon(
modifier = Modifier
.size(30.dp)
.align(Alignment.Center),
imageVector = Icons.Default.Menu,
contentDescription = "Catalogue"
)
}
}
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colorScheme.primary),
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(horizontal = 1.dp)
.height(36.dp),
onClick = {
menuVisibility = !menuVisibility
})
{
Box(Modifier.fillMaxHeight())
{
Text(
text = videoScreenViewModel.videoLibrary.classes.getOrNull(
tabIndex
)
?: "",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.CenterStart)
.padding(horizontal = 8.dp),
maxLines = 1
)
}
}
Row(
modifier = Modifier
.height(36.dp)
.widthIn(max = 240.dp)
.background(colorScheme.primary, RoundedCornerShape(8.dp))
.padding(horizontal = 6.dp)
) {
Icon(
modifier = Modifier
.size(30.dp)
.align(Alignment.CenterVertically),
imageVector = Icons.Default.Search,
contentDescription = "Catalogue"
)
Spacer(Modifier.width(4.dp))
BasicTextField(
value = searchFilter,
onValueChange = { searchFilter = it },
textStyle = LocalTextStyle.current.copy(
fontSize = 18.sp,
color = Color.White,
textAlign = TextAlign.Start
),
singleLine = true,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}
HorizontalDivider(
Modifier.padding(4.dp),
2.dp,
DividerDefaults.color
)
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Adaptive(160.dp),
contentPadding = PaddingValues(8.dp),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(
8.dp
),
state = state,
modifier = Modifier.fillMaxSize()
) {
items(
items = vb,
key = { "${it.first}/${it.second}" }
) { video ->
Box(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
) {
if(video.second.isNotEmpty())
VideoCard(video.second, navController, videoScreenViewModel)
}
}
}
}
AnimatedVisibility(
visible = menuVisibility,
enter = slideInHorizontally(initialOffsetX = { full -> full }),
exit = slideOutHorizontally(targetOffsetX = { full -> full }),
modifier = Modifier.align(Alignment.CenterEnd)
) {
Card(
Modifier
.fillMaxHeight()
.width(250.dp)
.align(Alignment.CenterEnd),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colorScheme.surface)
)
{
LazyColumn {
items(videoScreenViewModel.videoLibrary.classes) { item ->
CatalogueItemRow(
item = Pair(
videoScreenViewModel.videoLibrary.classes.indexOf(item),
item
),
onItemClick = {
menuVisibility = false
videoScreenViewModel.setTabIndex(
videoScreenViewModel.videoLibrary.classes.indexOf(
item
)
)
}
)
}
}
}
}
}
}
}
@Composable
fun CatalogueItemRow(
item: Pair<Int, String>,
onItemClick: (Pair<Int, String>) -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
Card(
modifier = Modifier
.clickable { onItemClick(item) }
.padding(4.dp)
.padding(horizontal = 4.dp)
.heightIn(min = 28.dp)
.width(250.dp),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = colorScheme.primary)
) {
Text(
text = item.second,
fontSize = 14.sp,
maxLines = 1,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}

View File

@@ -0,0 +1,77 @@
package com.acitelight.aether.viewModel
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.model.BookMark
import com.acitelight.aether.model.Comic
import com.acitelight.aether.model.ComicRecord
import com.acitelight.aether.model.ComicRecordDatabase
import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.RecentManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ComicGridViewModel @Inject constructor(
@ApplicationContext val context: Context,
val mediaManager: MediaManager,
val recentManager: RecentManager,
val apiClient: ApiClient
) : ViewModel()
{
var coverHeight by mutableStateOf(220.dp)
var maxHeight = 0.dp
var imageLoader: ImageLoader? = null
var comic = mutableStateOf<Comic?>(null)
val chapterList = mutableStateListOf<BookMark>()
var db: ComicRecordDatabase? = null
var record = mutableStateOf<ComicRecord?>(null)
init {
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(apiClient.getClient()))
}
.build()
db = try{
ComicRecordDatabase.getDatabase(context)
}catch (e: Exception) {
print(e.message)
} as ComicRecordDatabase?
}
fun resolve(id: String)
{
viewModelScope.launch {
if(comic.value == null) {
comic.value = mediaManager.queryComicInfoSingle(id)
recentManager.pushComic(context, id)
val c = comic.value!!
for (i in c.comic.bookmarks) {
chapterList.add(i)
}
}else comic.value = mediaManager.queryComicInfoSingle(id)
}
}
fun updateProcess(id: String, callback: () -> Unit)
{
viewModelScope.launch {
record.value = db?.userDao()?.getById(id.toInt())
callback()
}
}
}

View File

@@ -0,0 +1,78 @@
package com.acitelight.aether.viewModel
import android.content.Context
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.model.Comic
import com.acitelight.aether.model.ComicRecord
import com.acitelight.aether.model.ComicRecordDatabase
import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.MediaManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ComicPageViewModel @Inject constructor(
val mediaManager: MediaManager,
@ApplicationContext private val context: Context,
val apiClient: ApiClient
) : ViewModel()
{
var imageLoader: ImageLoader? = null
var comic = mutableStateOf<Comic?>(null)
var pageList = mutableStateListOf<String>()
var title = mutableStateOf<String>("")
var listState: LazyListState? = null
var showPlane = mutableStateOf(true)
var db: ComicRecordDatabase
init{
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(apiClient.getClient()))
}
.build()
listState = LazyListState(0, 0)
db = ComicRecordDatabase.getDatabase(context)
}
@Composable
fun Resolve(id: String, page: Int)
{
if(comic.value != null) return
LaunchedEffect(id, page) {
viewModelScope.launch {
comic.value = mediaManager.queryComicInfoSingle(id)
comic.value?.let {
pageList.addAll(it.comic.list)
title.value = it.comic.comic_name
listState?.scrollToItem(index = page)
updateProcess(page)
}
}
}
}
fun updateProcess(page: Int)
{
if(comic.value == null) return
viewModelScope.launch {
db.userDao().insert(ComicRecord(id = comic.value!!.id.toInt(), name = comic.value!!.comic.comic_name, position = page))
}
}
}

View File

@@ -1,24 +1,71 @@
package com.acitelight.aether.viewModel package com.acitelight.aether.viewModel
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.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.service.ApiClient
import com.acitelight.aether.service.MediaManager import com.acitelight.aether.service.MediaManager
import kotlinx.coroutines.flow.MutableStateFlow import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow import dagger.hilt.android.qualifiers.ApplicationContext
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,
val apiClient: ApiClient
) : ViewModel() {
init var imageLoader: ImageLoader? = null;
{
// viewModelScope.launch { val searchFilter = mutableStateOf("")
// val l = MediaManager.listComics() val comics = mutableStateListOf<Comic>()
// _comics.value = l.map { MediaManager.queryComicInfo(it) } val excluded = mutableStateListOf<String>()
// } val included = mutableStateListOf<String>()
val tags = mutableStateListOf<String>()
private val counter = mutableMapOf<String, Int>()
fun insertItem(newItem: String) {
val newCount = (counter[newItem] ?: 0) + 1
counter[newItem] = newCount
if (newItem !in tags) {
val insertIndex = tags.indexOfFirst { counter[it]!! < newCount }
.takeIf { it >= 0 } ?: tags.size
tags.add(insertIndex, newItem)
} else {
var currentIndex = tags.indexOf(newItem)
while (currentIndex > 0 && counter[tags[currentIndex - 1]]!! < newCount) {
tags[currentIndex] = tags[currentIndex - 1]
tags[currentIndex - 1] = newItem
currentIndex--
}
}
}
init {
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(apiClient.getClient()))
}
.build()
viewModelScope.launch {
val l = mediaManager.listComics()
val m = mediaManager.queryComicInfoBulk(l)
if(m != null) {
comics.addAll(m.sortedBy { it.id.toInt() }.reversed())
tags.addAll(m.flatMap { it.comic.tags }.groupingBy { it }.eachCount()
.entries.sortedByDescending { it.value }
.map { it.key })
}
}
} }
} }

View File

@@ -1,105 +1,38 @@
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
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,
val apiClient: ApiClient
) : 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(apiClient.getClient()))
} }
.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)
}
} }
} }
} }

View File

@@ -0,0 +1,14 @@
package com.acitelight.aether.viewModel
import androidx.lifecycle.ViewModel
import com.acitelight.aether.service.ApiClient
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class LiveScreenViewModel @Inject constructor(
val apiClient: ApiClient
) : ViewModel(){
}

View File

@@ -1,122 +1,145 @@
package com.acitelight.aether.viewModel package com.acitelight.aether.viewModel
import android.app.Application
import android.content.Context import android.content.Context
import android.widget.Toast import android.widget.Toast
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri
import androidx.datastore.preferences.core.edit import androidx.lifecycle.ViewModel
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.AndroidViewModel
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.model.Video
import com.acitelight.aether.service.ApiClient import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.AuthManager import com.acitelight.aether.service.AuthManager
import com.acitelight.aether.service.MediaManager import com.acitelight.aether.service.MediaManager
import kotlinx.coroutines.flow.Flow import com.acitelight.aether.service.SettingsDataStoreManager
import kotlinx.coroutines.flow.MutableStateFlow import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
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.coroutines.withContext
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import javax.inject.Inject
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") private val apiClient: ApiClient,
private val authManager: AuthManager
) : ViewModel() {
val userNameFlow: Flow<String> = dataStore.data.map { preferences -> val username = mutableStateOf("")
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 privateKey = mutableStateOf("") val privateKey = mutableStateOf("")
val url = mutableStateOf(""); val url = mutableStateOf("")
val cert = mutableStateOf("") val cert = mutableStateOf("")
val pak = 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{
apiClient.apply(context, url.value, if(uss.first()) cert.value else "")
authManager.fetchToken(
username.value,
settingsDataStoreManager.privateKeyFlow.first()
)!!
Global.loggedIn = true
withContext(Dispatchers.IO)
{
(context as AetherApp).abyssService?.proxy?.config(apiClient.getBase().toUri().host!!, 4096)
context.abyssService?.downloader?.init()
}
}catch(e: Exception)
{
Global.loggedIn = false
withContext(Dispatchers.IO)
{
(context as AetherApp).abyssService?.downloader?.init()
}
Toast.makeText(context, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
}
} }
} }
fun updateServer(u: String, c: String, context: Context) fun onUseSelfSignedCheckedChange(isChecked: Boolean) {
viewModelScope.launch {
settingsDataStoreManager.saveUseSelfSigned(isChecked)
}
}
fun updateServer(u: String, c: String)
{ {
viewModelScope.launch { 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()
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( 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)
@@ -124,4 +147,22 @@ class MeScreenViewModel(application: Application) : AndroidViewModel(application
} }
} }
} }
fun sendPacket(p: String)
{
val b = (p + "\r\n").toByteArray(Charsets.UTF_8)
viewModelScope.launch {
withContext(Dispatchers.IO) {
val addr = InetAddress.getByName(apiClient.getDomain())
val socket = DatagramSocket()
val packet = DatagramPacket(
b, b.size, addr, 4096
)
socket.send(packet)
}
}
}
} }

View File

@@ -0,0 +1,309 @@
package com.acitelight.aether.viewModel
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.Global.updateRelate
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoDownloadItemState
import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.FetchManager
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.VideoLibrary
import com.acitelight.aether.view.pages.toHex
import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.FetchListener
import com.tonyodev.fetch2.Status
import com.tonyodev.fetch2core.DownloadBlock
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
@HiltViewModel
class TransmissionScreenViewModel @Inject constructor(
val fetchManager: FetchManager,
@ApplicationContext val context: Context,
val videoLibrary: VideoLibrary,
val mediaManager: MediaManager,
val apiClient: ApiClient
) : ViewModel() {
var imageLoader: ImageLoader? = null
val downloads: SnapshotStateList<VideoDownloadItemState> = mutableStateListOf()
// map id -> state object reference (no index bookkeeping)
private val idToState: MutableMap<Int, VideoDownloadItemState> = mutableMapOf()
fun modelToVideo(model: VideoDownloadItemState): Video? {
val fv = videoLibrary.classesMap.map { it.value }.flatten()
return fv.firstOrNull { it.klass == model.klass && it.id == model.vid }
}
private val fetchListener = object : FetchListener {
override fun onAdded(download: Download) {
handleUpsert(download)
}
override fun onQueued(download: Download, waitingOnNetwork: Boolean) {
handleUpsert(download)
}
override fun onWaitingNetwork(download: Download) {
}
override fun onProgress(
download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long
) {
handleUpsert(download)
}
override fun onPaused(download: Download) {
handleUpsert(download)
}
override fun onResumed(download: Download) {
handleUpsert(download)
}
override fun onCompleted(download: Download) {
handleUpsert(download)
if (download.extras.getString("type", "") == "main") {
val ii = videoLibrary.classesMap[download.extras.getString(
"class",
""
)]?.indexOfFirst { it.id == download.extras.getString("id", "") }
if (ii != null) {
val newi =
videoLibrary.classesMap[download.extras.getString("class", "")]?.get(ii)
if (newi != null) videoLibrary.classesMap[download.extras.getString(
"class",
""
)]?.set(
ii, newi.toLocal(context.getExternalFilesDir(null)!!.path)
)
}
}
}
override fun onCancelled(download: Download) {
handleUpsert(download)
}
override fun onRemoved(download: Download) {
handleRemove(download.id)
}
override fun onDeleted(download: Download) {
handleRemove(download.id)
}
override fun onDownloadBlockUpdated(
download: Download, downloadBlock: DownloadBlock, totalBlocks: Int
) {
handleUpsert(download)
}
override fun onStarted(
download: Download, downloadBlocks: List<DownloadBlock>, totalBlocks: Int
) {
handleUpsert(download)
}
override fun onError(
download: Download, error: com.tonyodev.fetch2.Error, throwable: Throwable?
) {
handleUpsert(download)
}
}
private fun handleUpsert(download: Download) {
viewModelScope.launch(Dispatchers.Main) {
upsertOnMain(download)
}
val state = downloadToState(download)
if (!videoLibrary.classes.contains(state.klass)) videoLibrary.classes.add(state.klass)
if (!videoLibrary.classesMap.containsKey(state.klass)) videoLibrary.classesMap[state.klass] =
mutableStateListOf()
if (videoLibrary.classesMap[state.klass]?.any { it.id == state.vid } != true) {
viewModelScope.launch(Dispatchers.IO) {
val v = mediaManager.queryVideo(state.klass, state.vid, state)
if (v != null) {
videoLibrary.classesMap[state.klass]?.add(v)
}
}
}
}
private fun handleRemove(id: Int) {
viewModelScope.launch(Dispatchers.Main) {
removeOnMain(id)
}
}
private fun upsertOnMain(download: Download) {
val existing = idToState[download.id]
if (existing != null) {
// update fields in-place -> minimal recomposition
existing.filePath = download.file
existing.fileName = download.request.extras.getString("name", "")
existing.url = download.url
existing.progress = download.progress
existing.status = download.status
existing.downloadedBytes = download.downloaded
existing.totalBytes = download.total
} else {
// new item: add to head (or tail depending on preference)
val newState = downloadToState(download)
downloads.add(0, newState)
idToState[newState.id] = newState
}
}
private fun removeOnMain(id: Int) {
val state = idToState.remove(id)
if (state != null) {
downloads.remove(state)
} else {
val idx = downloads.indexOfFirst { it.id == id }
if (idx >= 0) {
val removed = downloads.removeAt(idx)
idToState.remove(removed.id)
}
}
}
private fun downloadToState(download: Download): VideoDownloadItemState {
val filePath = download.file
return VideoDownloadItemState(
id = download.id,
fileName = download.request.extras.getString("name", ""),
filePath = filePath,
url = download.url,
progress = download.progress,
status = download.status,
downloadedBytes = download.downloaded,
totalBytes = download.total,
klass = download.extras.getString("class", ""),
vid = download.extras.getString("id", ""),
type = download.extras.getString("type", "")
)
}
// UI actions delegated to FetchManager
fun pause(id: Int) = fetchManager.pause(id)
fun resume(id: Int) = fetchManager.resume(id)
fun retry(id: Int) = fetchManager.retry(id)
fun delete(id: Int) {
fetchManager.delete(id) {
viewModelScope.launch(Dispatchers.Main) { removeOnMain(id) }
}
}
override fun onCleared() {
super.onCleared()
fetchManager.removeListener()
}
suspend fun playStart(model: VideoDownloadItemState, navigator: NavHostController)
{
val downloaded = fetchManager.getAllDownloadsAsync().filter {
it.status == Status.COMPLETED && it.extras.getString(
"class",
""
) != "comic" && it.extras.getString(
"type",
""
) == "main"
}
val jsonQuery = downloaded.map {
File(
context.getExternalFilesDir(null),
"videos/${
it.extras.getString(
"class",
""
)
}/${it.extras.getString("id", "")}/summary.json"
).readText()
}
.map {
Json.decodeFromString<Video>(it)
.toLocal(context.getExternalFilesDir(null)!!.path)
}
updateRelate(
jsonQuery,
jsonQuery.first { it.id == model.vid && it.klass == model.klass }
)
val playList = mutableListOf<String>()
val fv = videoLibrary.classesMap.map { it.value }.flatten()
val video = fv.firstOrNull { it.klass == model.klass && it.id == model.vid }
if (video != null) {
val group = fv.filter { it.klass == video.klass && it.video.group == video.video.group && it.video.group != "null" }
for (i in group.sortedWith(compareBy(naturalOrder()) { it.video.name })) {
playList.add("${i.klass}/${i.id}")
}
}
val route = "video_player_route/${(playList.joinToString(",") + "|${model.vid}").toHex()}"
withContext(Dispatchers.Main) {
navigator.navigate(route)
}
}
init {
imageLoader = ImageLoader.Builder(context).components {
add(OkHttpNetworkFetcherFactory(apiClient.getClient()))
}.build()
viewModelScope.launch {
fetchManager.setListener(fetchListener)
val downloaded = fetchManager.getAllDownloadsAsync()
downloads.clear()
idToState.clear()
downloaded.forEach { d ->
val s = downloadToState(d)
downloads.add(s)
idToState[s.id] = s
if (d.extras.getString("type", "") == "main") {
if (!videoLibrary.classes.contains(s.klass))
videoLibrary.classes.add(s.klass)
if (!videoLibrary.classesMap.containsKey(s.klass)) videoLibrary.classesMap[s.klass] =
mutableStateListOf()
if (videoLibrary.classesMap[s.klass]?.any { it.id == s.vid } != true) {
val v = mediaManager.queryVideo(s.klass, s.vid, s)
if (v != null) {
videoLibrary.classesMap[s.klass]?.add(v)
}
}
}
}
}
}
}

View File

@@ -1,123 +1,360 @@
package com.acitelight.aether.viewModel package com.acitelight.aether.viewModel
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Player.STATE_ENDED
import androidx.media3.common.Player.STATE_READY import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.Tracks
import androidx.media3.common.text.Cue
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import coil3.ImageLoader import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.Global import com.acitelight.aether.model.KeyImage
import com.acitelight.aether.model.Video import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoQueryIndex import com.acitelight.aether.model.VideoQueryIndex
import com.acitelight.aether.service.ApiClient.createOkHttp import com.acitelight.aether.model.VideoRecord
import com.acitelight.aether.model.VideoRecordDatabase
import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.MediaManager import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.RecentManager import com.acitelight.aether.service.RecentManager
import com.acitelight.aether.view.hexToString import com.acitelight.aether.service.VideoLibrary
import com.acitelight.aether.view.pages.formatTime
import com.acitelight.aether.view.pages.hexToString
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.File
import javax.inject.Inject
class VideoPlayerViewModel() : ViewModel() @HiltViewModel
{ class VideoPlayerViewModel @Inject constructor(
@ApplicationContext private val context: Context,
val mediaManager: MediaManager,
val recentManager: RecentManager,
val videoLibrary: VideoLibrary,
val apiClient: ApiClient
) : ViewModel() {
var showPlaylist by mutableStateOf(false)
var isLandscape by mutableStateOf(false)
var tabIndex by mutableIntStateOf(0) var tabIndex by mutableIntStateOf(0)
var isPlaying by mutableStateOf(true) var isPlaying by mutableStateOf(true)
var playProcess by mutableFloatStateOf(0.0f) var playProcess by mutableFloatStateOf(0.0f)
var planeVisibility by mutableStateOf(true) var planeVisibility by mutableStateOf(true)
var isLongPressing by mutableStateOf(false) var isLongPressing by mutableStateOf(false)
var dragging by mutableStateOf(false)
var thumbUp by mutableIntStateOf(0) // -1 : Not dragging
var thumbDown by mutableIntStateOf(0) // 0 : Seek
var star by mutableStateOf(false) // 1 : Volume
// 2 : Brightness
private var _init: Boolean = false; var draggingPurpose by mutableIntStateOf(-1)
var locked 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(apiClient.getClient())
var imageLoader: ImageLoader? = null; private val defaultDataSourceFactory by lazy {
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 oId = videoId.hexToString()
var spec = "-1"
var vs: MutableList<List<String>>
if(oId.contains("|"))
{
vs = oId.split("|")[0].split(",").map { it.split("/") }.toMutableList()
spec = oId.split("|")[1]
}else{
vs = oId.split(",").map { it.split("/") }.toMutableList()
}
imageLoader = ImageLoader.Builder(context)
.components { .components {
add(OkHttpNetworkFetcherFactory(createOkHttp())) add(OkHttpNetworkFetcherFactory(apiClient.getClient()))
} }
.build() .build()
remember { viewModelScope.launch {
viewModelScope.launch { videos = mediaManager.queryVideoBulk(vs.first()[0], vs.map { it[1] })!!
video = MediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!!
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 { val ii = database.userDao().getAll().first()
override fun onPlaybackStateChanged(playbackState: Int) { val ix = ii.filter { it.id in videos.map{ m -> m.id } }.maxByOrNull { it.time }
if (playbackState == STATE_READY) {
startPlaying = true startPlay(
} if(spec != "-1")
videos.first { it.id == spec}
else if (ix != null)
videos.first { it.id == ix.id }
else videos.first()
)
startListen()
}
}
/**
* Try to resolve the given subtitle pathOrUrl to a Uri.
* - If it's a local path and file exists -> Uri.fromFile
* - If it's a http(s) URL -> try HEAD; if HEAD unsupported, try GET with Range: bytes=0-1
* - Return null when unreachable / 404 / not exist
*/
private suspend fun tryResolveSubtitleUri(pathOrUrl: String?): Uri? =
withContext(Dispatchers.IO) {
if (pathOrUrl.isNullOrBlank()) return@withContext null
val trimmed = pathOrUrl.trim()
// Remote URL case (http/https)
if (trimmed.startsWith("http://", ignoreCase = true) || trimmed.startsWith(
"https://",
ignoreCase = true
)
) {
try {
val client = apiClient.getClient()
val headReq = Request.Builder().url(trimmed).head().build()
val headResp = try {
client.newCall(headReq).execute()
} catch (_: Exception) {
null
}
headResp?.use { resp ->
val code = resp.code
if (code == 200 || code == 206) {
return@withContext trimmed.toUri()
}
if (code == 404) {
return@withContext null
}
}
val rangeReq = Request.Builder()
.url(trimmed)
.addHeader("Range", "bytes=0-1")
.get()
.build()
val rangeResp = try {
client.newCall(rangeReq).execute()
} catch (_: Exception) {
null
}
rangeResp?.use { resp ->
val code = resp.code
if (code == 206) {
return@withContext trimmed.toUri()
} }
override fun onRenderedFirstFrame() { 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(apiClient)
player?.apply {
stop()
clearMediaItems()
}
recentManager.pushVideo(context, VideoQueryIndex(video.klass, video.id))
val subtitleCandidate = video.getSubtitle(apiClient).trim()
val subtitleUri = tryResolveSubtitleUri(subtitleCandidate)
if (player == null) {
val trackSelector = DefaultTrackSelector(context)
val builder = ExoPlayer.Builder(context)
.setMediaSourceFactory(DefaultMediaSourceFactory(defaultDataSourceFactory))
player = builder.setTrackSelector(trackSelector).build().apply {
addListener(object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
val trackSelector = player?.trackSelector
if (trackSelector is DefaultTrackSelector) {
val parameters = trackSelector.buildUponParameters()
.setSelectUndeterminedTextLanguage(true)
.build()
trackSelector.parameters = parameters
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
when(playbackState)
{
STATE_READY -> {
startPlaying = true
}
STATE_ENDED -> {
player?.seekTo(0)
player?.pause()
}
else -> {
}
}
}
override fun onRenderedFirstFrame() {
if (!renderedFirst) {
viewModelScope.launch {
val ii = database.userDao().get(currentId.value, currentKlass.value)
if (ii != null) {
player?.seekTo(ii.position)
Toast.makeText(
context,
"Recover from ${formatTime(ii.position)} ",
Toast.LENGTH_SHORT
).show()
}
}
}
renderedFirst = true
}
override fun onPlayerError(error: PlaybackException) {
print(error.message)
}
override fun onCues(lcues: MutableList<Cue>) {
cues = lcues
}
})
}
}
val url = video.getVideo(apiClient)
val videoUri = if (video.isLocal) Uri.fromFile(File(url)) else url.toUri()
val mediaItem: MediaItem = if (subtitleUri != null) {
val subConfig = MediaItem.SubtitleConfiguration.Builder(subtitleUri)
.setMimeType("text/vtt")
.build()
MediaItem.Builder()
.setUri(videoUri)
.setSubtitleConfigurations(listOf(subConfig))
.build()
} else {
MediaItem.fromUri(videoUri)
}
player?.apply {
setMediaItem(mediaItem)
prepare()
playWhenReady = true
}
}
var player: ExoPlayer? = null
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
_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 })
)
}
} }
} }

View File

@@ -1,77 +1,130 @@
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.State import androidx.compose.runtime.State
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.lifecycle.ViewModel
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 com.acitelight.aether.dataStore import com.acitelight.aether.Global
import com.acitelight.aether.model.Video import com.acitelight.aether.model.Video
import com.acitelight.aether.service.ApiClient.createOkHttp import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.FetchManager
import com.acitelight.aether.service.MediaManager import com.acitelight.aether.service.MediaManager
import kotlinx.coroutines.flow.Flow import com.acitelight.aether.service.VideoLibrary
import com.tonyodev.fetch2.Status
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.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
class VideoScreenViewModel(application: Application) : AndroidViewModel(application)
{ @HiltViewModel
class VideoScreenViewModel @Inject constructor(
val fetchManager: FetchManager,
@ApplicationContext val context: Context,
val mediaManager: MediaManager,
val videoLibrary: VideoLibrary,
val apiClient: ApiClient
) : ViewModel() {
private val _tabIndex = mutableIntStateOf(0) private val _tabIndex = mutableIntStateOf(0)
val tabIndex: State<Int> = _tabIndex val tabIndex: State<Int> = _tabIndex
// val videos = mutableStateListOf<Video>() var imageLoader: ImageLoader? = null
// private val _klasses = MutableStateFlow<List<String>>(emptyList()) var menuVisibility = mutableStateOf(false)
var classes = mutableStateListOf<String>() var searchFilter = mutableStateOf("")
val classesMap = mutableStateMapOf<String, SnapshotStateList<Video>>() var doneInit = mutableStateOf(false)
var imageLoader: ImageLoader? = null;
@Composable
fun SetupClient()
{
val context = LocalContext.current
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
}
suspend fun init() { suspend fun init() {
classes.addAll(MediaManager.listVideoKlasses()) fetchManager.configured.filter { it }.first()
for(it in classes)
{ if (Global.loggedIn) {
classesMap[it] = mutableStateListOf<Video>() videoLibrary.classes.addAll(
mediaManager.listVideoKlasses().filter { it !in videoLibrary.classes }
)
if(videoLibrary.classes.isEmpty())
return
var i = 0
for (it in videoLibrary.classes) {
videoLibrary.updatingMap[i++] = false
if(!videoLibrary.classesMap.containsKey(it))
videoLibrary.classesMap[it] = mutableStateListOf()
}
videoLibrary.updatingMap[0] = true
val vl =
mediaManager.queryVideoBulk(videoLibrary.classes[0], mediaManager.queryVideoKlasses(videoLibrary.classes[0]))
if (vl != null) {
val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name })
val existsId = videoLibrary.classesMap[videoLibrary.classes[0]]?.map { it.id }
videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(r.filter { existsId == null || it.id !in existsId })
}
}
else {
videoLibrary.classes.add("Offline")
videoLibrary.updatingMap[0] = true
videoLibrary.classesMap["Offline"] = mutableStateListOf()
val downloaded = fetchManager.getAllDownloadsAsync().filter {
it.status == Status.COMPLETED &&
it.extras.getString("class", "") != "comic" &&
it.extras.getString("type", "") == "main"
}
val jsonQuery = downloaded.map{ File(
context.getExternalFilesDir(null),
"videos/${it.extras.getString("class", "")}/${it.extras.getString("id", "")}/summary.json").readText() }
.map { Json.decodeFromString<Video>(it).toLocal(context.getExternalFilesDir(null)!!.path) }
videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(jsonQuery)
} }
MediaManager.listVideos(classes[0]){ doneInit.value = true
v -> classesMap[classes[0]]?.add(v) }
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 })
val existsId = videoLibrary.classesMap[videoLibrary.classes[index]]?.map { it.id }
videoLibrary.classesMap[videoLibrary.classes[index]]?.addAll(r.filter { existsId == null || it.id !in existsId })
}
} }
} }
fun setTabIndex(index: Int) suspend fun download(video: Video) {
{ fetchManager.startVideoDownload(video)
viewModelScope.launch()
{
_tabIndex.intValue = index;
MediaManager.listVideos(classes[index])
{
v -> classesMap[classes[index]]?.add(v)
}
}
} }
init { init {
viewModelScope.launch { imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(apiClient.getClient()))
}
.build()
viewModelScope.launch(Dispatchers.IO) {
init() init()
} }
} }

View 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>

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -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
} }

View File

@@ -1,5 +1,7 @@
[versions] [versions]
agp = "8.12.1" accompanistNavigationAnimation = "0.37.3"
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,26 +9,42 @@ 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"
gson = "2.13.1" fetch2 = "3.4.1"
kotlin = "2.2.10" fetch2okhttp = "3.4.1"
gson = "2.13.2"
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.4"
activityCompose = "1.10.1" activityCompose = "1.11.0"
composeBom = "2025.08.00" composeBom = "2025.09.01"
media3Common = "1.8.0" media3Common = "1.8.0"
media3Exoplayer = "1.8.0" media3Exoplayer = "1.8.0"
media3ExoplayerFfmpeg = "1.8.0"
media3Ui = "1.8.0" media3Ui = "1.8.0"
navigationCompose = "2.9.3" navigationCompose = "2.9.5"
okhttp = "5.1.0" okhttp = "5.1.0"
persistentcookiejar = "1.0.1"
repo = "Tag"
retrofit = "3.0.0" retrofit = "3.0.0"
retrofit2KotlinxSerializationConverter = "1.0.0" retrofit2KotlinxSerializationConverter = "1.0.0"
media3DatasourceOkhttp = "1.8.0" media3DatasourceOkhttp = "1.8.0"
roomCompiler = "2.8.1"
roomKtx = "2.8.1"
roomRuntime = "2.8.1"
ksp = "2.1.21-2.0.2"
hilt = "2.57.2"
hilt-navigation-compose = "1.3.0"
composeMaterialCore = "1.5.2"
constraintlayout = "2.2.1"
animation = "1.9.2"
[libraries] [libraries]
accompanist-navigation-animation = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanistNavigationAnimation" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" }
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
@@ -34,10 +52,16 @@ androidx-media3-common = { module = "androidx.media3:media3-common", version.ref
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" } androidx-media3-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" }
@@ -54,12 +78,22 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
persistentcookiejar = { module = "com.github.franmontiel:PersistentCookieJar", version.ref = "persistentcookiejar" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" } retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" }
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" } androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-android-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
androidx-compose-material-core = { group = "androidx.wear.compose", name = "compose-material-core", version.ref = "composeMaterialCore" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
androidx-compose-animation = { group = "androidx.compose.animation", name = "animation", version.ref = "animation" }
[plugins] [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" }

View File

@@ -16,6 +16,9 @@ dependencyResolutionManagement {
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
maven {
url = uri("https://jitpack.io")
}
} }
} }