7 Commits

16 changed files with 419 additions and 233 deletions

2
.gitignore vendored
View File

@@ -15,7 +15,7 @@ captures/
*.aab
*.apk
output-metadata.json
release/
# IntelliJ
*.iml
.idea/

View File

@@ -26,6 +26,7 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
@@ -67,6 +68,7 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.media3.datasource.okhttp)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -1,66 +0,0 @@
import android.content.Context
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.ByteArrayDataSource
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
private fun InMemoryVideoPlayer(
modifier: Modifier = Modifier,
videoData: ByteArray
) {
val context = LocalContext.current
val exoPlayer = remember(context, videoData) {
createExoPlayer(context, videoData)
}
DisposableEffect(Unit) {
onDispose {
exoPlayer.release()
}
}
AndroidView(
modifier = modifier,
factory = {
PlayerView(it).apply {
player = exoPlayer
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
}
)
}
@androidx.annotation.OptIn(UnstableApi::class)
private fun createExoPlayer(context: Context, videoData: ByteArray): ExoPlayer {
val byteArrayDataSource = ByteArrayDataSource(videoData)
val factory = DataSource.Factory {
byteArrayDataSource
}
val mediaSource = ProgressiveMediaSource.Factory(factory)
.createMediaSource(MediaItem.fromUri("data://local/video.mp4"))
return ExoPlayer.Builder(context).build().apply {
setMediaSource(mediaSource)
prepare()
playWhenReady = false
}
}

View File

@@ -1,25 +1,106 @@
package com.acitelight.aether.service
import android.content.Context
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.CertificatePinner
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.io.ByteArrayInputStream
import java.security.KeyStore
import java.security.cert.Certificate
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
object ApiClient {
const val base: String = "http://192.168.1.213/"
var base: String = ""
var domain: String = ""
var cert: String = ""
private val json = Json {
ignoreUnknownKeys = true
}
private val retrofit = Retrofit.Builder()
fun loadCertificateFromString(pemString: String): X509Certificate {
val certificateFactory = CertificateFactory.getInstance("X.509")
val decodedPem = pemString
.replace("-----BEGIN CERTIFICATE-----", "")
.replace("-----END CERTIFICATE-----", "")
.replace("\\s+".toRegex(), "")
val decodedBytes = android.util.Base64.decode(decodedPem, android.util.Base64.DEFAULT)
ByteArrayInputStream(decodedBytes).use { inputStream ->
return certificateFactory.generateCertificate(inputStream) as X509Certificate
}
}
fun createOkHttpClientWithDynamicCert(trustedCert: X509Certificate): OkHttpClient {
try {
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(null, null)
setCertificateEntry("ca", trustedCert)
}
val tmfAlgorithm = TrustManagerFactory.getDefaultAlgorithm()
val tmf = TrustManagerFactory.getInstance(tmfAlgorithm).apply {
init(keyStore)
}
val trustManager = tmf.trustManagers.first { it is X509TrustManager } as X509TrustManager
val sslContext = SSLContext.getInstance("TLS").apply {
init(null, arrayOf(trustManager), null)
}
return OkHttpClient.Builder()
.sslSocketFactory(sslContext.socketFactory, trustManager)
.build()
} catch (e: Exception) {
throw RuntimeException("Failed to create OkHttpClient with dynamic certificate", e)
}
}
fun createOkHttp(): OkHttpClient
{
return createOkHttpClientWithDynamicCert(loadCertificateFromString(cert))
}
private fun createRetrofit(): Retrofit {
val okHttpClient = createOkHttpClientWithDynamicCert(loadCertificateFromString(cert))
return Retrofit.Builder()
.baseUrl(base)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
}
val api: ApiInterface by lazy {
retrofit.create(ApiInterface::class.java)
var api: ApiInterface? = null
fun apply(url: String, crt: String)
{
try{
domain = url.toHttpUrlOrNull()?.host !!
cert = crt
base = url
api = createRetrofit().create(ApiInterface::class.java)
}catch (e: Exception)
{
api = null
base = ""
domain = ""
cert = ""
}
}
}

View File

@@ -9,12 +9,12 @@ import java.security.PrivateKey
import java.security.Signature
object AuthManager {
suspend fun fetchToken(baseUrl: String, username: String, privateKey: String): String? {
suspend fun fetchToken(username: String, privateKey: String): String? {
val api = ApiClient.api
var challengeBase64 = ""
try{
challengeBase64 = api.getChallenge(username).string()
challengeBase64 = api!!.getChallenge(username).string()
}catch (e: Exception)
{
print(e.message)
@@ -23,7 +23,7 @@ object AuthManager {
val signedBase64 = signChallenge(db64(privateKey), db64(challengeBase64))
return try {
api.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string()
api!!.verifyChallenge(username, ChallengeResponse(response = signedBase64)).string()
} catch (e: Exception) {
e.printStackTrace()
null

View File

@@ -15,7 +15,7 @@ object MediaManager
{
try
{
val j = ApiClient.api.getVideoClasses(token)
val j = ApiClient.api!!.getVideoClasses(token)
return j.toList()
}catch(e: Exception)
{
@@ -23,23 +23,24 @@ object MediaManager
}
}
suspend fun listVideos(klass: String): List<Video>
suspend fun listVideos(klass: String, callback: (Video) -> Unit)
{
val j = ApiClient.api!!.queryVideoClasses(klass, token)
for(it in j)
{
try {
val j = ApiClient.api.queryVideoClasses(klass, token)
return j.map{
queryVideo(klass, it)!!
}.toList()
callback(queryVideo(klass, it)!!)
}catch (e: Exception)
{
return listOf()
}
}
}
suspend fun queryVideo(klass: String, id: String): Video?
{
try {
val j = ApiClient.api.queryVideo(klass, id, token)
val j = ApiClient.api!!.queryVideo(klass, id, token)
return Video(klass = klass, id = id, token=token, j)
}catch (e: Exception)
{
@@ -49,11 +50,13 @@ object MediaManager
suspend fun listComics() : List<String>
{
return ApiClient.api.getComicCollections()
// TODO: try
return ApiClient.api!!.getComicCollections()
}
suspend fun queryComicInfo(c: String) : Comic
{
return ApiClient.api.queryComicInfo(c)
// TODO: try
return ApiClient.api!!.queryComicInfo(c)
}
}

View File

@@ -1,6 +1,7 @@
package com.acitelight.aether.service
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoQueryIndex
import kotlinx.coroutines.Dispatchers
@@ -50,11 +51,15 @@ object RecentManager
try{
val r = Json.decodeFromString<List<VideoQueryIndex>>(content)
val vn = r.map{
MediaManager.queryVideo(it.klass, it.id)
}.filter { it != null }
recent.clear()
for(it in r)
{
val v = MediaManager.queryVideo(it.klass, it.id)
if(v != null)
recent.add(recent.size, v)
}
_recent.value = vn.map { it!! }
return r
}catch (e: Exception)
{
@@ -74,6 +79,7 @@ object RecentManager
{
val temp = o[0]
val index = o.indexOf(video)
recent.removeAt(index)
o[0] = o[index]
o[index] = temp
}
@@ -85,15 +91,10 @@ object RecentManager
if(o.size >= 21)
o.removeAt(o.size - 1)
val vn = o.map{
MediaManager.queryVideo(it.klass, it.id)
}.filter { it != null }
_recent.value = vn.map { it!! }
recent.add(0, MediaManager.queryVideo(video.klass, video.id)!!)
writeFile(context, "recent.json", Json.encodeToString(o))
}
}
private val _recent = MutableStateFlow<List<Video>>(emptyList())
val recent: StateFlow<List<Video>> = _recent
val recent = mutableStateListOf<Video>()
}

View File

@@ -34,7 +34,6 @@ fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel(), navContro
{
if(Global.loggedIn)
homeScreenViewModel.Init()
val recent by RecentManager.recent.collectAsState()
LazyColumn(modifier = Modifier.fillMaxWidth())
{
@@ -49,17 +48,17 @@ fun HomeScreen(homeScreenViewModel: HomeScreenViewModel = viewModel(), navContro
HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color)
for(i in recent)
for(i in RecentManager.recent)
{
MiniVideoCard(
modifier = Modifier
.padding(horizontal = 12.dp),
i,
{
Global.sameClassVideos = recent
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

@@ -8,10 +8,13 @@ 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.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Security
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
@@ -25,19 +28,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.acitelight.aether.viewModel.MeScreenViewModel
@Composable
fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel())
{
fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel()) {
val context = LocalContext.current
var username by meScreenViewModel.username;
var privateKey by meScreenViewModel.privateKey;
var url by meScreenViewModel.url
var cert by meScreenViewModel.cert
Column(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
@@ -45,11 +51,13 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel())
verticalArrangement = Arrangement.Top
) {
// Card component for a clean, contained UI block
item{
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
)
{
Column(
modifier = Modifier
.padding(16.dp)
@@ -59,7 +67,9 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel())
Text(
text = "Account Setting",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 16.dp).align(Alignment.Start)
modifier = Modifier
.padding(bottom = 16.dp)
.align(Alignment.Start)
)
// Username input field
@@ -96,6 +106,69 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel())
onClick = {
meScreenViewModel.updateAccount(username, privateKey, context)
},
modifier = Modifier.fillMaxWidth(),
enabled = privateKey != "******"
) {
Text("Save")
}
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
{
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Server Setting",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.padding(bottom = 16.dp)
.align(Alignment.Start)
)
// Username input field
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("Url") },
leadingIcon = {
Icon(Icons.Default.Link, contentDescription = "Url")
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Private key input field
OutlinedTextField(
value = cert,
onValueChange = { cert = it },
label = { Text("Cert") },
singleLine = false,
maxLines = 40,
minLines = 20,
modifier = Modifier.fillMaxWidth(),
textStyle = TextStyle(
fontSize = 8.sp
)
)
Spacer(modifier = Modifier.height(24.dp))
// Save Button
Button(
onClick = {
meScreenViewModel.updateServer(url, cert, context)
},
modifier = Modifier.fillMaxWidth()
) {
Text("Save")
@@ -103,4 +176,6 @@ fun MeScreen(meScreenViewModel: MeScreenViewModel = viewModel())
}
}
}
}
}

View File

@@ -88,6 +88,7 @@ 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
@@ -572,7 +573,7 @@ fun VideoPlayerPortal(videoPlayerViewModel: VideoPlayerViewModel, navController:
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)
}
}
@@ -683,13 +684,13 @@ fun HorizontalGallery(videoPlayerViewModel: VideoPlayerViewModel)
contentPadding = PaddingValues(horizontal = 24.dp)
) {
items(videoPlayerViewModel.video?.getGallery() ?: listOf()) { it ->
SingleImageItem(img = it)
SingleImageItem(img = it, videoPlayerViewModel.imageLoader!!)
}
}
}
@Composable
fun SingleImageItem(img: KeyImage) {
fun SingleImageItem(img: KeyImage, imageLoader: ImageLoader) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(img.url)
@@ -700,7 +701,8 @@ fun SingleImageItem(img: KeyImage) {
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
contentScale = ContentScale.Crop
contentScale = ContentScale.Crop,
imageLoader = imageLoader
)
}
@@ -912,7 +914,7 @@ fun VideoPlayerLandscape(videoPlayerViewModel: VideoPlayerViewModel)
}
@Composable
fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit)
fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit, imageLoader: ImageLoader)
{
var isImageLoaded by remember { mutableStateOf(false) }
Card(
@@ -943,7 +945,8 @@ fun MiniVideoCard(modifier: Modifier, video: Video, onClick: () -> Unit)
modifier = Modifier
.width(128.dp).fillMaxHeight()
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
contentScale = ContentScale.Crop,
imageLoader = imageLoader
)
Column (

View File

@@ -1,6 +1,5 @@
package com.acitelight.aether.view
import android.R.id.tabs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -20,12 +19,7 @@ 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.MaterialTheme
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -39,19 +33,16 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.acitelight.aether.model.Video
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.viewModel.VideoScreenViewModel
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.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.compose.ui.text.style.TextOverflow
import androidx.navigation.NavHostController
import coil3.request.ImageRequest
import com.acitelight.aether.Global
import kotlinx.coroutines.flow.first
import java.nio.charset.Charset
fun String.toHex(): String {
@@ -72,7 +63,8 @@ fun String.hexToString(charset: Charset = Charsets.UTF_8): String {
@Composable
fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navController: NavHostController)
{
val videoList by videoScreenViewModel.videos.collectAsState()
val tabIndex by videoScreenViewModel.tabIndex;
videoScreenViewModel.SetupClient()
Column(
modifier = Modifier.fillMaxSize() // 或至少 fillMaxWidth()
@@ -86,11 +78,14 @@ fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navCon
horizontalArrangement = Arrangement.spacedBy(8.dp)
)
{
items(videoList) { video ->
if(videoScreenViewModel.classes.isNotEmpty())
{
items(videoScreenViewModel.classesMap[videoScreenViewModel.classes[tabIndex]] ?: mutableStateListOf()) { video ->
VideoCard(video, navController, videoScreenViewModel)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -98,12 +93,11 @@ fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = viewModel(), navCon
fun TopRow(videoScreenViewModel: VideoScreenViewModel)
{
val tabIndex by videoScreenViewModel.tabIndex;
val klasses by videoScreenViewModel.klasses.collectAsState();
if(klasses.isEmpty()) return
if(videoScreenViewModel.classes.isEmpty()) return
ScrollableTabRow (selectedTabIndex = tabIndex) {
klasses.forEachIndexed { index, title ->
videoScreenViewModel.classes.forEachIndexed { index, title ->
Tab(
selected = tabIndex == index,
onClick = { videoScreenViewModel.setTabIndex(index) },
@@ -115,15 +109,14 @@ fun TopRow(videoScreenViewModel: VideoScreenViewModel)
@Composable
fun VideoCard(video: Video, navController: NavHostController, videoScreenViewModel: VideoScreenViewModel) {
val videoList by videoScreenViewModel.videos.collectAsState()
val tabIndex by videoScreenViewModel.tabIndex;
Card(
shape = RoundedCornerShape(6.dp),
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight(),
onClick = {
Global.sameClassVideos = videoList
Global.sameClassVideos = videoScreenViewModel.classesMap[videoScreenViewModel.classes[tabIndex]] ?: mutableStateListOf()
val route = "video_player_route/${ "${video.klass}/${video.id}".toHex() }"
navController.navigate(route)
}
@@ -142,7 +135,8 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod
contentDescription = null,
modifier = Modifier
.fillMaxSize(),
contentScale = ContentScale.Crop
contentScale = ContentScale.Crop,
imageLoader = videoScreenViewModel.imageLoader!!
)
Text(

View File

@@ -11,11 +11,15 @@ import androidx.compose.ui.platform.LocalContext
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import okhttp3.OkHttpClient
import com.acitelight.aether.Global
import com.acitelight.aether.dataStore
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoQueryIndex
import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.AuthManager
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.MediaManager.token
@@ -32,6 +36,8 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
private val dataStore = application.dataStore
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 ->
preferences[USER_NAME_KEY] ?: ""
@@ -41,7 +47,16 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
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(){
@@ -49,6 +64,11 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
_init = true
val context = LocalContext.current
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
remember {
viewModelScope.launch {
RecentManager.Query(context)
@@ -60,13 +80,16 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
viewModelScope.launch {
val u = userNameFlow.first()
val p = privateKeyFlow.first()
val ur = urlFlow.first()
val c = certFlow.first()
if(u=="" || p=="") return@launch
if(u=="" || p=="" || ur=="" || c=="") return@launch
try{
ApiClient.apply(ur, c)
if (MediaManager.token == "null")
MediaManager.token = AuthManager.fetchToken(
ApiClient.base,
u,
p
)!!
@@ -74,6 +97,7 @@ class HomeScreenViewModel(application: Application) : AndroidViewModel(applicati
Global.loggedIn = true
}catch(e: Exception)
{
Global.loggedIn = false
print(e.message)
}
}

View File

@@ -25,6 +25,8 @@ class MeScreenViewModel(application: Application) : AndroidViewModel(application
private val dataStore = application.dataStore
private val USER_NAME_KEY = stringPreferencesKey("user_name")
private val PRIVATE_KEY = stringPreferencesKey("private_key")
private val URL_KEY = stringPreferencesKey("url")
private val CERT_KEY = stringPreferencesKey("cert")
val userNameFlow: Flow<String> = dataStore.data.map { preferences ->
preferences[USER_NAME_KEY] ?: ""
@@ -34,13 +36,58 @@ class MeScreenViewModel(application: Application) : AndroidViewModel(application
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 url = mutableStateOf("");
val cert = mutableStateOf("")
init {
viewModelScope.launch {
username.value = userNameFlow.first()
privateKey.value = if (privateKeyFlow.first() == "") "" else "******"
url.value = urlFlow.first()
cert.value = certFlow.first()
}
}
fun updateServer(u: String, c: String, context: Context)
{
viewModelScope.launch {
dataStore.edit { preferences ->
preferences[URL_KEY] = u
preferences[CERT_KEY] = c
}
Global.loggedIn = false
val us = userNameFlow.first()
val u = urlFlow.first()
val c = certFlow.first()
val p = privateKeyFlow.first()
if (u == "" || c == "" || p == "" || us == "") return@launch
try {
ApiClient.apply(u, c)
MediaManager.token = AuthManager.fetchToken(
us,
p
)!!
Global.loggedIn = true
Toast.makeText(context, "Server Updated", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
print(e.message)
Toast.makeText(context, "Invalid Account or Server Information", Toast.LENGTH_SHORT).show()
}
}
}
@@ -58,11 +105,13 @@ class MeScreenViewModel(application: Application) : AndroidViewModel(application
val u = userNameFlow.first()
val p = privateKeyFlow.first()
if (u == "" || p == "") return@launch
val ur = urlFlow.first()
val c = certFlow.first()
if (u == "" || p == "" || ur == "" || c == "") return@launch
try {
MediaManager.token = AuthManager.fetchToken(
ApiClient.base,
u,
p
)!!

View File

@@ -16,10 +16,15 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.Global
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoQueryIndex
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.RecentManager
import com.acitelight.aether.view.hexToString
@@ -46,6 +51,10 @@ class VideoPlayerViewModel() : ViewModel()
var renderedFirst = false
var video: Video? = null
val dataSourceFactory = OkHttpDataSource.Factory(createOkHttp())
var imageLoader: ImageLoader? = null;
@OptIn(UnstableApi::class)
@Composable
fun Init(videoId: String)
{
@@ -53,11 +62,20 @@ class VideoPlayerViewModel() : ViewModel()
val context = LocalContext.current
val v = videoId.hexToString()
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
remember {
viewModelScope.launch {
video = MediaManager.queryVideo(v.split("/")[0], v.split("/")[1])!!
RecentManager.Push(context, VideoQueryIndex(v.split("/")[0], v.split("/")[1]))
_player = ExoPlayer.Builder(context).build().apply {
_player = ExoPlayer
.Builder(context)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
.build().apply {
val url = video?.getVideo() ?: ""
val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem)

View File

@@ -1,53 +1,60 @@
package com.acitelight.aether.viewModel
import android.app.Application
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.platform.LocalContext
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.acitelight.aether.Global
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.dataStore
import com.acitelight.aether.model.Video
import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.AuthManager
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.MediaManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
class VideoScreenViewModel(application: Application) : AndroidViewModel(application)
{
private val dataStore = application.dataStore
private val USER_NAME_KEY = stringPreferencesKey("user_name")
private val PRIVATE_KEY = stringPreferencesKey("private_key")
val userNameFlow: Flow<String> = dataStore.data.map { preferences ->
preferences[USER_NAME_KEY] ?: ""
}
val privateKeyFlow: Flow<String> = dataStore.data.map { preferences ->
preferences[PRIVATE_KEY] ?: ""
}
private val _tabIndex = mutableIntStateOf(0)
val tabIndex: State<Int> = _tabIndex
// val videos = mutableStateListOf<Video>()
// private val _klasses = MutableStateFlow<List<String>>(emptyList())
var classes = mutableStateListOf<String>()
val classesMap = mutableStateMapOf<String, SnapshotStateList<Video>>()
private val _videos = MutableStateFlow<List<Video>>(emptyList())
val videos: StateFlow<List<Video>> = _videos
private val _klasses = MutableStateFlow<List<String>>(emptyList())
val klasses: StateFlow<List<String>> = _klasses;
var imageLoader: ImageLoader? = null;
@Composable
fun SetupClient()
{
val context = LocalContext.current
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
}
suspend fun init() {
_klasses.value = MediaManager.listVideoKlasses()
val p = MediaManager.listVideos(_klasses.value.first())
_videos.value = p
classes.addAll(MediaManager.listVideoKlasses())
for(it in classes)
{
classesMap[it] = mutableStateListOf<Video>()
}
MediaManager.listVideos(classes[0]){
v -> classesMap[classes[0]]?.add(v)
}
}
fun setTabIndex(index: Int)
@@ -55,8 +62,11 @@ class VideoScreenViewModel(application: Application) : AndroidViewModel(applicat
viewModelScope.launch()
{
_tabIndex.intValue = index;
val p = MediaManager.listVideos(_klasses.value[index])
_videos.value = p
MediaManager.listVideos(classes[index])
{
v -> classesMap[classes[index]]?.add(v)
}
}
}

View File

@@ -14,7 +14,6 @@ junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
kotlinxSerializationJson = "1.9.0"
kotlinxSerializationJsonVersion = "1.9.0"
lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1"
composeBom = "2025.08.00"
@@ -25,9 +24,7 @@ navigationCompose = "2.9.3"
okhttp = "5.1.0"
retrofit = "3.0.0"
retrofit2KotlinxSerializationConverter = "1.0.0"
retrofitVersion = "3.0.0"
tinkAndroid = "1.18.0"
tweetnaclJava = "1.0.0"
media3DatasourceOkhttp = "1.8.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -38,7 +35,6 @@ androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", versi
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Ui" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
bcprov-jdk15on = { module = "org.bouncycastle:bcprov-jdk15on", version.ref = "bcprovJdk15on" }
bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprovJdk18on" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilCompose" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilNetworkOkhttp" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "converterGson" }
@@ -57,13 +53,10 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinx-serialization-json-v163 = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJsonVersion" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-v2110 = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofitVersion" }
retrofit2-kotlinx-serialization-converter = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version.ref = "retrofit2KotlinxSerializationConverter" }
tink-android = { module = "com.google.crypto.tink:tink-android", version.ref = "tinkAndroid" }
tweetnacl-java = { module = "com.github.InstantWebP2P:tweetnacl-java", version.ref = "tweetnaclJava" }
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3DatasourceOkhttp" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }