[feat] Complete video caching system

This commit is contained in:
acite
2025-09-15 03:15:43 +08:00
parent ad51c5da2f
commit e94249aa8f
22 changed files with 613 additions and 211 deletions

View File

@@ -179,7 +179,7 @@ fun AppNavigation() {
composable(Screen.Transmission.route) {
CardPage(title = "Tasks") {
TransmissionScreen()
TransmissionScreen(navigator = navController)
}
}
composable(Screen.Me.route) {
@@ -236,6 +236,8 @@ fun BottomNavigationBar(navController: NavController) {
Screen.Transmission,
Screen.Me
) else listOf(
Screen.Video,
Screen.Transmission,
Screen.Me
)

View File

@@ -1,5 +1,9 @@
package com.acitelight.aether.model
import kotlinx.serialization.Serializable
@Serializable
data class Comment(
val content: String,
val username: String,

View File

@@ -13,7 +13,9 @@ class DownloadItemState(
progress: Int,
status: Status,
downloadedBytes: Long,
totalBytes: Long
totalBytes: Long,
klass: String,
vid: String
) {
var fileName by mutableStateOf(fileName)
var filePath by mutableStateOf(filePath)
@@ -22,4 +24,7 @@ class DownloadItemState(
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

@@ -1,6 +1,7 @@
package com.acitelight.aether.model
data class KeyImage(
val name: String,
val url: String,
val key: String
)

View File

@@ -1,30 +1,59 @@
package com.acitelight.aether.model
import com.acitelight.aether.service.ApiClient
import kotlinx.serialization.Serializable
import java.security.KeyPair
class Video constructor(
@Serializable
class Video(
val isLocal: Boolean,
val localBase: String,
val klass: String,
val id: String,
val token: String,
val video: VideoResponse
) {
fun getCover(): String
{
return "${ApiClient.getBase()}api/video/$klass/$id/cover?token=$token"
fun getCover(): String {
return if (isLocal)
"$localBase/videos/$klass/$id/cover.jpg"
else
"${ApiClient.getBase()}api/video/$klass/$id/cover?token=$token"
}
fun getVideo(): String
{
return "${ApiClient.getBase()}api/video/$klass/$id/av?token=$token"
fun getVideo(): String {
return if (isLocal)
"$localBase/videos/$klass/$id/video.mp4"
else
"${ApiClient.getBase()}api/video/$klass/$id/av?token=$token"
}
fun getGallery(): List<KeyImage>
{
return video.gallery.map{
KeyImage(url = "${ApiClient.getBase()}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it")
fun getGallery(): List<KeyImage> {
return if (isLocal)
video.gallery.map {
KeyImage(
name = it,
url = "$localBase/videos/$klass/$id/gallery/$it",
key = "$klass/$id/gallery/$it"
)
} else video.gallery.map {
KeyImage(
name = it,
url = "${ApiClient.getBase()}api/video/$klass/$id/gallery/$it?token=$token",
key = "$klass/$id/gallery/$it"
)
}
}
fun toLocal(localBase: String): Video
{
return Video(
isLocal = true,
localBase = localBase,
klass = klass,
id = id,
token = "",
video = video
)
}
}

View File

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

View File

@@ -105,9 +105,8 @@ class AbyssTunnelProxy @Inject constructor(
val read = localIn.read(buffer)
if (read <= 0)
break
Log.i("Delay Analyze", "Read $read Bytes from HttpClient")
abyss.write(buffer, 0, read)
Log.i("Delay Analyze", "Wrote $read Bytes to Remote Abyss")
}
}
@@ -118,9 +117,7 @@ class AbyssTunnelProxy @Inject constructor(
val n = abyss.read(buffer, 0, buffer.size)
if (n <= 0)
break
Log.i("Delay Analyze", "Read $n Bytes from Remote Abyss")
localOut.write(buffer, 0, n)
Log.i("Delay Analyze", "Wrote $n Bytes to HttpClient")
}
}
}

View File

@@ -217,13 +217,10 @@ object ApiClient {
domain = selectedUrl.toHttpUrlOrNull()?.host ?: ""
cert = crt
base = selectedUrl
withContext(Dispatchers.IO)
{
(context as AetherApp).abyssService?.proxy?.config(base.toUri().host!!, 4096)
context.abyssService?.downloader?.init()
(context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
}
api = createRetrofit().create(ApiInterface::class.java)
Log.i("Delay Analyze", "Start Abyss Hello")

View File

@@ -1,6 +1,7 @@
package com.acitelight.aether.service
import android.content.Context
import androidx.compose.runtime.mutableStateOf
import com.acitelight.aether.Screen
import com.acitelight.aether.model.Video
import com.acitelight.aether.service.ApiClient.createOkHttp
@@ -13,7 +14,18 @@ import com.tonyodev.fetch2.Status
import com.tonyodev.fetch2core.Extras
import com.tonyodev.fetch2okhttp.OkHttpDownloader
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@@ -23,21 +35,25 @@ class FetchManager @Inject constructor(
) {
private var fetch: Fetch? = null
private var listener: FetchListener? = null
private var client: OkHttpClient? = null
val configured = MutableStateFlow(false)
fun init()
{
client = createOkHttp()
val fetchConfiguration = FetchConfiguration.Builder(context)
.setDownloadConcurrentLimit(8)
.setHttpDownloader(OkHttpDownloader(createOkHttp()))
.setHttpDownloader(OkHttpDownloader(client))
.build()
fetch = Fetch.Impl.getInstance(fetchConfiguration)
configured.update { true }
}
// listener management
fun setListener(l: FetchListener) {
if (fetch == null)
return
suspend fun setListener(l: FetchListener) {
configured.filter { it }.first()
listener?.let { fetch?.removeListener(it) }
listener = l
fetch?.addListener(l)
@@ -51,14 +67,24 @@ class FetchManager @Inject constructor(
}
// query downloads
fun getAllDownloads(callback: (List<Download>) -> Unit) {
if (fetch == null) init()
suspend fun getAllDownloads(callback: (List<Download>) -> Unit) {
configured.filter { it }.first()
fetch?.getDownloads { list -> callback(list) } ?: callback(emptyList())
}
fun getDownloadsByStatus(status: Status, callback: (List<Download>) -> Unit) {
if (fetch == null) init()
fetch?.getDownloadsWithStatus(status) { list -> callback(list) } ?: callback(emptyList())
suspend fun getAllDownloadsAsync(): List<Download>
{
configured.filter { it }.first()
val completed = MutableStateFlow(false)
var r = listOf<Download>()
fetch?.getDownloads { list ->
r = list
completed.update { true }
}
completed.filter { it }.first()
return r
}
// operations
@@ -80,13 +106,13 @@ class FetchManager @Inject constructor(
} ?: callback?.invoke()
}
private fun enqueue(request: Request, onEnqueued: ((Request) -> Unit)? = null, onError: ((com.tonyodev.fetch2.Error) -> Unit)? = null) {
if (fetch == null) init()
private suspend fun enqueue(request: Request, onEnqueued: ((Request) -> Unit)? = null, onError: ((com.tonyodev.fetch2.Error) -> Unit)? = null) {
configured.filter { it }.first()
fetch?.enqueue(request, { r -> onEnqueued?.invoke(r) }, { e -> onError?.invoke(e) })
}
private fun getVideosDirectory() {
val appFilesDir = context.filesDir
val appFilesDir = context.getExternalFilesDir(null)
val videosDir = File(appFilesDir, "videos")
if (!videosDir.exists()) {
@@ -94,12 +120,52 @@ class FetchManager @Inject constructor(
}
}
fun startVideoDownload(video: Video)
suspend fun downloadFile(
client: OkHttpClient,
url: String,
destFile: File
): Result<Unit> = withContext(Dispatchers.IO) {
try {
val request = okhttp3.Request.Builder().url(url).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext Result.failure(IOException("Unexpected code $response"))
}
destFile.parentFile?.mkdirs()
response.body.byteStream().use { input ->
destFile.outputStream().use { output ->
input.copyTo(output)
}
}
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun startVideoDownload(video: Video)
{
val path = File(context.filesDir, "videos/${video.klass}/${video.id}/video.mp4")
val path = File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/video.mp4")
val request = Request(video.getVideo(), path.path).apply {
extras = Extras(mapOf("name" to video.video.name, "id" to video.id, "class" to video.klass))
}
downloadFile(
client!!,
video.getCover(),
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/cover.jpg"))
enqueue(request)
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/summary.json").writeText(Json.encodeToString(video))
for(p in video.getGallery())
{
downloadFile(
client!!,
p.url,
File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/gallery/${p.name}"))
}
}
}

View File

@@ -5,14 +5,18 @@ import com.acitelight.aether.model.BookMark
import com.acitelight.aether.model.Comic
import com.acitelight.aether.model.ComicResponse
import com.acitelight.aether.model.Video
import com.tonyodev.fetch2.Status
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MediaManager @Inject constructor(
val fetchManager: FetchManager,
@ApplicationContext val context: Context
)
{
var token: String = "null"
@@ -43,23 +47,86 @@ class MediaManager @Inject constructor(
suspend fun queryVideo(klass: String, id: String): Video?
{
val downloaded = fetchManager.getAllDownloadsAsync().filter {
it.status == Status.COMPLETED &&
it.extras.getString("id", "") == id &&
it.extras.getString("class", "") == klass
}
if(!downloaded.isEmpty())
{
val jsonString = File(
context.getExternalFilesDir(null),
"videos/$klass/$id/summary.json"
).readText()
return Json.decodeFromString<Video>(jsonString).toLocal(context.getExternalFilesDir(null)?.path!!)
}
try {
val j = ApiClient.api!!.queryVideo(klass, id, token)
return Video(klass = klass, id = id, token=token, isLocal = false, video = j)
return Video(klass = klass, id = id, token=token, isLocal = false, localBase = "", video = j)
}catch (e: Exception)
{
return null
}
}
suspend fun queryVideoBulk(klass: String, id: List<String>): List<Video>?
{
suspend fun queryVideoBulk(klass: String, id: List<String>): List<Video>? {
return try {
val completedDownloads = fetchManager.getAllDownloadsAsync()
.filter { it.status == Status.COMPLETED }
val localIds = mutableSetOf<String>()
val remoteIds = mutableListOf<String>()
for (videoId in id) {
if (completedDownloads.any {
it.extras.getString("id", "") == videoId &&
it.extras.getString("class", "") == klass
}) {
localIds.add(videoId)
} else {
remoteIds.add(videoId)
}
}
val localVideos = localIds.mapNotNull { videoId ->
val localFile = File(
context.getExternalFilesDir(null),
"videos/$klass/$videoId/summary.json"
)
if (localFile.exists()) {
try {
val j = ApiClient.api!!.queryVideoBulk(klass, id, token)
return j.zip(id).map {Video(klass = klass, id = it.second, token=token, isLocal = false, video = it.first)}
}catch (e: Exception)
{
return null
val jsonString = localFile.readText()
Json.decodeFromString<Video>(jsonString).toLocal(
context.getExternalFilesDir(null)?.path ?: ""
)
} catch (e: Exception) {
null
}
} else {
null
}
}
val remoteVideos = if (remoteIds.isNotEmpty()) {
val j = ApiClient.api!!.queryVideoBulk(klass, remoteIds, token)
j.zip(remoteIds).map {
Video(
klass = klass,
id = it.second,
token = token,
isLocal = false,
localBase = "",
video = it.first
)
}
} else {
emptyList()
}
localVideos + remoteVideos
} catch (e: Exception) {
null
}
}

View File

@@ -91,7 +91,7 @@ class RecentManager @Inject constructor(
recent.removeAt(index)
}
recent.add(0, mediaManager.queryVideoBulk(video.klass, listOf(video.id))!![0])
recent.add(0, mediaManager.queryVideo(video.klass, video.id)!!)
if(recent.size >= 21)

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

@@ -40,14 +40,12 @@ import com.acitelight.aether.model.Comic
import com.acitelight.aether.viewModel.ComicGridViewModel
@Composable
fun ComicGridView(comicId: String, navController: NavHostController, comicGridViewModel: ComicGridViewModel = viewModel()) {
comicGridViewModel.SetupClient()
fun ComicGridView(comicId: String, navController: NavHostController, comicGridViewModel: ComicGridViewModel = hiltViewModel<ComicGridViewModel>()) {
comicGridViewModel.resolve(comicId.hexToString())
comicGridViewModel.updateProcess(comicId.hexToString()){}
ToggleFullScreen(false)
val colorScheme = MaterialTheme.colorScheme
val context = LocalContext.current
val comic by comicGridViewModel.comic
val record by comicGridViewModel.record

View File

@@ -138,7 +138,6 @@ fun ComicScreen(
navController: NavHostController,
comicScreenViewModel: ComicScreenViewModel = hiltViewModel<ComicScreenViewModel>()
) {
comicScreenViewModel.SetupClient()
val included = comicScreenViewModel.included
val state = rememberLazyGridState()
val colorScheme = MaterialTheme.colorScheme

View File

@@ -1,13 +1,18 @@
package com.acitelight.aether.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Stop
@@ -32,24 +37,42 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import androidx.navigation.Navigator
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.Global.updateRelate
import com.acitelight.aether.model.DownloadItemState
import com.acitelight.aether.model.Video
import com.acitelight.aether.viewModel.TransmissionScreenViewModel
import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.FetchListener
import com.tonyodev.fetch2.Status
import com.tonyodev.fetch2core.DownloadBlock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
@Composable
fun TransmissionScreen(transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel<TransmissionScreenViewModel>())
{
fun TransmissionScreen(navigator: NavHostController, transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel<TransmissionScreenViewModel>()) {
val downloads = transmissionScreenViewModel.downloads
Surface(modifier = Modifier.fillMaxWidth()) {
LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(downloads, key = { it.id }) { item ->
DownloadCard(
navigator = navigator,
viewModel = transmissionScreenViewModel,
model = item,
onPause = { transmissionScreenViewModel.pause(item.id) },
onResume = { transmissionScreenViewModel.resume(item.id) },
@@ -59,11 +82,12 @@ fun TransmissionScreen(transmissionScreenViewModel: TransmissionScreenViewModel
}
}
}
}
@Composable
private fun DownloadCard(
navigator: NavHostController,
viewModel: TransmissionScreenViewModel,
model: DownloadItemState,
onPause: () -> Unit,
onResume: () -> Unit,
@@ -71,24 +95,100 @@ private fun DownloadCard(
onDelete: () -> Unit
) {
Card(
shape = RoundedCornerShape(8.dp),
elevation = CardDefaults.cardElevation(4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp).background(Color.Transparent)
.padding(8.dp)
.background(Color.Transparent)
.clickable(onClick = {
if(model.status == Status.COMPLETED)
{
viewModel.viewModelScope.launch(Dispatchers.IO)
{
val downloaded = viewModel.fetchManager.getAllDownloadsAsync().filter {
it.status == Status.COMPLETED && it.extras.getString("isComic", "") != "true"
}
val jsonQuery = downloaded.map{ File(
viewModel.context.getExternalFilesDir(null),
"videos/${it.extras.getString("class", "")}/${it.extras.getString("id", "")}/summary.json").readText() }
.map { Json.decodeFromString<Video>(it).toLocal(viewModel.context.getExternalFilesDir(null)!!.path) }
updateRelate(
jsonQuery, jsonQuery.first { it.id == model.vid && it.klass == model.klass }
)
val route = "video_player_route/${"${model.klass}/${model.vid}".toHex()}"
withContext(Dispatchers.Main){
navigator.navigate(route)
}
}
}
})
) {
Column(modifier = Modifier
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = model.fileName, style = MaterialTheme.typography.titleMedium)
}
// progress percentage
Text(text = "${model.progress}%", modifier = Modifier.padding(start = 8.dp))
}
Box(Modifier
.fillMaxWidth()
.padding(top = 5.dp))
{
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.align(Alignment.CenterStart)
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(
File(
viewModel.context.getExternalFilesDir(null),
"videos/${model.klass}/${model.vid}/cover.jpg"
)
)
.diskCacheKey("${model.klass}/${model.vid}/cover")
.build(),
contentDescription = null,
modifier = Modifier.heightIn(max = 100.dp),
contentScale = ContentScale.Fit
)
}
Column(Modifier.align(Alignment.BottomEnd)) {
Text(
text = "${model.progress}%",
modifier = Modifier
.padding(start = 8.dp)
.align(Alignment.End)
)
Text(
modifier = Modifier
.padding(start = 8.dp)
.align(Alignment.End),
text = "%.2f MB/%.2f MB".format(
model.downloadedBytes / (1024.0 * 1024.0),
model.totalBytes / (1024.0 * 1024.0)
),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
// progress bar
LinearProgressIndicator(
progress = { model.progress.coerceIn(0, 100) / 100f },
@@ -117,9 +217,13 @@ private fun DownloadCard(
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
}
}
Status.PAUSED, Status.QUEUED -> {
Button(onClick = onResume) {
Icon(imageVector = Icons.Default.PlayArrow, contentDescription = "Resume")
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Resume"
)
Text(text = " Resume", modifier = Modifier.padding(start = 6.dp))
}
Button(onClick = onCancel) {
@@ -127,16 +231,21 @@ private fun DownloadCard(
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
}
}
Status.COMPLETED -> {
Button(onClick = onDelete) {
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
Text(text = " Delete", modifier = Modifier.padding(start = 6.dp))
}
}
else -> {
// for FAILED, CANCELLED, REMOVED etc.
Button(onClick = onResume) {
Icon(imageVector = Icons.Default.PlayArrow, contentDescription = "Retry")
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Retry"
)
Text(text = " Retry", modifier = Modifier.padding(start = 6.dp))
}
Button(onClick = onDelete) {

View File

@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -71,13 +72,14 @@ fun String.hexToString(charset: Charset = Charsets.UTF_8): String {
}
@Composable
fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = hiltViewModel<VideoScreenViewModel>(), navController: NavHostController)
{
fun VideoScreen(
videoScreenViewModel: VideoScreenViewModel = hiltViewModel<VideoScreenViewModel>(),
navController: NavHostController
) {
val tabIndex by videoScreenViewModel.tabIndex;
videoScreenViewModel.SetupClient()
Column(
modifier = Modifier.fillMaxSize() // 或至少 fillMaxWidth()
modifier = Modifier.fillMaxSize()
) {
TopRow(videoScreenViewModel);
@@ -88,9 +90,11 @@ fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = hiltViewModel<Video
horizontalArrangement = Arrangement.spacedBy(8.dp)
)
{
if(videoScreenViewModel.classes.isNotEmpty())
{
items(videoScreenViewModel.classesMap[videoScreenViewModel.classes[tabIndex]] ?: mutableStateListOf()) { video ->
if (videoScreenViewModel.videoLibrary.classes.isNotEmpty()) {
items(
videoScreenViewModel.videoLibrary.classesMap[videoScreenViewModel.videoLibrary.classes[tabIndex]]
?: mutableStateListOf()
) { video ->
VideoCard(video, navController, videoScreenViewModel)
}
}
@@ -100,14 +104,16 @@ fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = hiltViewModel<Video
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopRow(videoScreenViewModel: VideoScreenViewModel)
{
fun TopRow(videoScreenViewModel: VideoScreenViewModel) {
val tabIndex by videoScreenViewModel.tabIndex;
if(videoScreenViewModel.classes.isEmpty()) return
if (videoScreenViewModel.videoLibrary.classes.isEmpty()) return
val colorScheme = MaterialTheme.colorScheme
ScrollableTabRow (selectedTabIndex = tabIndex, modifier = Modifier.background(colorScheme.surface)) {
videoScreenViewModel.classes.forEachIndexed { index, title ->
ScrollableTabRow(
selectedTabIndex = tabIndex,
modifier = Modifier.background(colorScheme.surface)
) {
videoScreenViewModel.videoLibrary.classes.forEachIndexed { index, title ->
Tab(
selected = tabIndex == index,
onClick = { videoScreenViewModel.setTabIndex(index) },
@@ -118,7 +124,11 @@ fun TopRow(videoScreenViewModel: VideoScreenViewModel)
}
@Composable
fun VideoCard(video: Video, navController: NavHostController, videoScreenViewModel: VideoScreenViewModel) {
fun VideoCard(
video: Video,
navController: NavHostController,
videoScreenViewModel: VideoScreenViewModel
) {
val tabIndex by videoScreenViewModel.tabIndex;
Card(
modifier = Modifier
@@ -126,7 +136,10 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod
.wrapContentHeight()
.combinedClickable(
onClick = {
updateRelate(videoScreenViewModel.classesMap[videoScreenViewModel.classes[tabIndex]] ?: mutableStateListOf(), video)
updateRelate(
videoScreenViewModel.videoLibrary.classesMap[videoScreenViewModel.videoLibrary.classes[tabIndex]]
?: mutableStateListOf(), video
)
val route = "video_player_route/${"${video.klass}/${video.id}".toHex()}"
navController.navigate(route)
},
@@ -134,7 +147,11 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod
videoScreenViewModel.viewModelScope.launch {
videoScreenViewModel.download(video)
}
Toast.makeText(videoScreenViewModel.context, "Start downloading ${video.video.name}", Toast.LENGTH_SHORT).show()
Toast.makeText(
videoScreenViewModel.context,
"Start downloading ${video.video.name}",
Toast.LENGTH_SHORT
).show()
}
),
shape = RoundedCornerShape(6.dp),
@@ -144,6 +161,7 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod
.fillMaxWidth()
) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover())
@@ -158,27 +176,54 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod
)
Text(
modifier = Modifier.align(Alignment.BottomEnd).padding(2.dp),
text = formatTime(video.video.duration), fontSize = 12.sp, fontWeight = FontWeight.Bold)
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(2.dp),
text = formatTime(video.video.duration),
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
Box(
Modifier
.fillMaxWidth()
.height(24.dp)
.background( brush = Brush.verticalGradient(
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.45f)
)
))
.align(Alignment.BottomCenter))
)
)
.align(Alignment.BottomCenter)
)
if (video.isLocal)
Card(Modifier
.align(Alignment.TopStart)
.padding(5.dp)
.widthIn(max = 46.dp)) {
Box(Modifier.fillMaxWidth())
{
Text(
modifier = Modifier.align(Alignment.Center),
text = "Local",
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
}
}
}
Text(
text = video.video.name,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
modifier = Modifier.padding(8.dp).background(Color.Transparent).heightIn(24.dp)
modifier = Modifier
.padding(8.dp)
.background(Color.Transparent)
.heightIn(24.dp)
)
Spacer(modifier = Modifier.weight(1f))
Row(

View File

@@ -1,5 +1,6 @@
package com.acitelight.aether.viewModel
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -17,11 +18,13 @@ import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.RecentManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ComicGridViewModel @Inject constructor(
@ApplicationContext val context: Context,
val mediaManager: MediaManager
) : ViewModel()
{
@@ -31,23 +34,18 @@ class ComicGridViewModel @Inject constructor(
var db: ComicRecordDatabase? = null
var record = mutableStateOf<ComicRecord?>(null)
@Composable
fun SetupClient()
{
val context = LocalContext.current
init {
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
db = remember {
try{
db = try{
ComicRecordDatabase.getDatabase(context)
}catch (e: Exception) {
print(e.message)
} as ComicRecordDatabase?
}
}
fun resolve(id: String)
{

View File

@@ -1,5 +1,6 @@
package com.acitelight.aether.viewModel
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.ui.platform.LocalContext
@@ -12,6 +13,7 @@ import com.acitelight.aether.model.ComicResponse
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.MediaManager
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -19,6 +21,7 @@ import javax.inject.Inject
@HiltViewModel
class ComicScreenViewModel @Inject constructor(
@ApplicationContext private val context: Context,
val mediaManager: MediaManager
) : ViewModel() {
@@ -48,18 +51,13 @@ class ComicScreenViewModel @Inject constructor(
}
}
@Composable
fun SetupClient()
{
val context = LocalContext.current
init {
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
}
init {
viewModelScope.launch {
val l = mediaManager.listComics()
val m = mediaManager.queryComicInfoBulk(l)

View File

@@ -5,11 +5,13 @@ import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.acitelight.aether.AetherApp
import com.acitelight.aether.Global
import com.acitelight.aether.dataStore
import com.acitelight.aether.model.Video
@@ -25,6 +27,8 @@ import javax.inject.Inject
import com.acitelight.aether.service.*
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@HiltViewModel
class MeScreenViewModel @Inject constructor(
@@ -59,10 +63,19 @@ class MeScreenViewModel @Inject constructor(
)!!
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
print(e.message)
withContext(Dispatchers.IO)
{
(context as AetherApp).abyssService?.downloader?.init()
}
Toast.makeText(context, "Error: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
@@ -94,6 +107,11 @@ class MeScreenViewModel @Inject constructor(
)!!
Global.loggedIn = true
withContext(Dispatchers.IO)
{
(context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
context.abyssService?.downloader?.init()
}
Toast.makeText(context, "Server Updated, Used Url: $usedUrl", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
print(e.message)
@@ -124,6 +142,11 @@ class MeScreenViewModel @Inject constructor(
)!!
Global.loggedIn = true
withContext(Dispatchers.IO)
{
(context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
context.abyssService?.downloader?.init()
}
Toast.makeText(context, "Account Updated", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
print(e.message)

View File

@@ -1,25 +1,28 @@
package com.acitelight.aether.viewModel
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.acitelight.aether.model.DownloadItemState
import com.acitelight.aether.service.FetchManager
import com.acitelight.aether.service.VideoLibrary
import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.FetchListener
import com.tonyodev.fetch2core.DownloadBlock
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.io.File
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
class TransmissionScreenViewModel @Inject constructor(
private val fetchManager: FetchManager
val fetchManager: FetchManager,
@ApplicationContext val context: Context,
private val videoLibrary: VideoLibrary
) : ViewModel() {
private val _downloads: SnapshotStateList<DownloadItemState> = mutableStateListOf()
val downloads: SnapshotStateList<DownloadItemState> = _downloads
@@ -37,11 +40,18 @@ class TransmissionScreenViewModel @Inject constructor(
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) }
override fun onCompleted(download: Download) {
val ii = videoLibrary.classesMap[download.extras.getString("class", "")]
?.indexOfFirst { it.id == download.extras.getString("id", "") }!!
val newi = videoLibrary.classesMap[download.extras.getString("class", "")]!![ii]
videoLibrary.classesMap[download.extras.getString("class", "")]!![ii] = newi.toLocal(context.getExternalFilesDir(null)!!.path)
handleUpsert(download)
}
override fun onCancelled(download: Download) { handleUpsert(download) }
override fun onRemoved(download: Download) { handleRemove(download.id) }
override fun onDeleted(download: Download) { handleRemove(download.id) }
override fun onDownloadBlockUpdated(download: Download, downloadBlock: DownloadBlock, blockCount: Int) { handleUpsert(download) }
override fun onDownloadBlockUpdated(download: Download, downloadBlock: DownloadBlock, totalBlocks: Int) { handleUpsert(download) }
override fun onStarted(
download: Download,
downloadBlocks: List<DownloadBlock>,
@@ -107,7 +117,9 @@ class TransmissionScreenViewModel @Inject constructor(
progress = download.progress,
status = download.status,
downloadedBytes = download.downloaded,
totalBytes = download.total
totalBytes = download.total,
klass = download.extras.getString("class", ""),
vid = download.extras.getString("id", "")
)
}
@@ -128,12 +140,13 @@ class TransmissionScreenViewModel @Inject constructor(
}
init {
viewModelScope.launch {
fetchManager.setListener(fetchListener)
viewModelScope.launch(Dispatchers.Main) {
withContext(Dispatchers.Main) {
fetchManager.getAllDownloads { list ->
_downloads.clear()
idToState.clear()
list.sortedByDescending { it.id }.forEach { d ->
list.sortedBy { it.extras.getString("name", "") }.forEach { d ->
val s = downloadToState(d)
_downloads.add(s)
idToState[s.id] = s
@@ -142,3 +155,4 @@ class TransmissionScreenViewModel @Inject constructor(
}
}
}
}

View File

@@ -1,6 +1,7 @@
package com.acitelight.aether.viewModel
import android.app.Activity
import android.net.Uri
import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
@@ -14,8 +15,10 @@ import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.util.Log
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
@@ -34,14 +37,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
@HiltViewModel
class VideoPlayerViewModel @Inject constructor(
val mediaManager: MediaManager,
val recentManager: RecentManager
) : ViewModel()
{
) : ViewModel() {
var tabIndex by mutableIntStateOf(0)
var isPlaying by mutableStateOf(true)
var playProcess by mutableFloatStateOf(0.0f)
@@ -69,8 +72,7 @@ class VideoPlayerViewModel @Inject constructor(
@OptIn(UnstableApi::class)
@Composable
fun Init(videoId: String)
{
fun Init(videoId: String) {
if (_init) return;
val context = LocalContext.current
val v = videoId.hexToString()
@@ -85,12 +87,14 @@ class VideoPlayerViewModel @Inject constructor(
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)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
_player = (if(video!!.isLocal) ExoPlayer.Builder(context) else ExoPlayer.Builder(context).setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)))
.build().apply {
val url = video?.getVideo() ?: ""
val mediaItem = MediaItem.fromUri(url)
val mediaItem = if (video!!.isLocal)
MediaItem.fromUri(Uri.fromFile(File(url)))
else
MediaItem.fromUri(url)
setMediaItem(mediaItem)
prepare()
playWhenReady = true
@@ -106,6 +110,10 @@ class VideoPlayerViewModel @Inject constructor(
super.onRenderedFirstFrame()
renderedFirst = true
}
override fun onPlayerError(error: PlaybackException) {
Log.e("ExoPlayer", "Playback error: ", error)
}
})
}
startListen()
@@ -116,8 +124,7 @@ class VideoPlayerViewModel @Inject constructor(
}
@OptIn(UnstableApi::class)
fun startListen()
{
fun startListen() {
CoroutineScope(Dispatchers.Main).launch {
while (_player?.isReleased != true) {
val __player = _player!!;

View File

@@ -13,86 +13,106 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.Global
import com.acitelight.aether.model.Video
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.FetchManager
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.RecentManager
import com.acitelight.aether.service.VideoLibrary
import com.tonyodev.fetch2.Status
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@HiltViewModel
class VideoScreenViewModel @Inject constructor(
private val fetchManager: FetchManager,
@ApplicationContext val context: Context,
val mediaManager: MediaManager,
val recentManager: RecentManager
) : ViewModel()
{
val recentManager: RecentManager,
val videoLibrary: VideoLibrary
) : ViewModel() {
private val _tabIndex = mutableIntStateOf(0)
val tabIndex: State<Int> = _tabIndex
// val videos = mutableStateListOf<Video>()
var classes = mutableStateListOf<String>()
val classesMap = mutableStateMapOf<String, SnapshotStateList<Video>>()
var imageLoader: ImageLoader? = null;
val updatingMap: MutableMap<Int, Boolean> = mutableMapOf()
@Composable
fun SetupClient()
suspend fun init() {
fetchManager.configured.filter { it }.first()
if (Global.loggedIn) {
videoLibrary.classes.addAll(mediaManager.listVideoKlasses())
var i = 0
for (it in videoLibrary.classes) {
videoLibrary.updatingMap[i++] = false
videoLibrary.classesMap[it] = mutableStateListOf<Video>()
}
videoLibrary.updatingMap[0] = true
val vl =
mediaManager.queryVideoBulk(videoLibrary.classes[0], mediaManager.queryVideoKlasses(videoLibrary.classes[0]))
if (vl != null) {
val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name })
videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(r)
}
} else {
videoLibrary.classes.add("Offline")
videoLibrary.updatingMap[0] = true
videoLibrary.classesMap["Offline"] = mutableStateListOf<Video>()
val downloaded = fetchManager.getAllDownloadsAsync().filter {
it.status == Status.COMPLETED && it.extras.getString("isComic", "") != "true"
}
val jsonQuery = downloaded.map{ File(
context.getExternalFilesDir(null),
"videos/${it.extras.getString("class", "")}/${it.extras.getString("id", "")}/summary.json").readText() }
.map { Json.decodeFromString<Video>(it).toLocal(context.getExternalFilesDir(null)!!.path) }
videoLibrary.classesMap[videoLibrary.classes[0]]?.addAll(jsonQuery)
}
}
fun setTabIndex(index: Int) {
viewModelScope.launch()
{
val context = LocalContext.current
_tabIndex.intValue = index;
if (videoLibrary.updatingMap[index] == true) return@launch
videoLibrary.updatingMap[index] = true
val vl = mediaManager.queryVideoBulk(
videoLibrary.classes[index],
mediaManager.queryVideoKlasses(videoLibrary.classes[index])
)
if (vl != null) {
val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name })
videoLibrary.classesMap[videoLibrary.classes[index]]?.addAll(r)
}
}
}
suspend fun download(video: Video) {
fetchManager.startVideoDownload(video)
}
init {
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
}
suspend fun init() {
classes.addAll(mediaManager.listVideoKlasses())
var i = 0
for(it in classes)
{
updatingMap[i++] = false
classesMap[it] = mutableStateListOf<Video>()
}
updatingMap[0] = true
val vl = mediaManager.queryVideoBulk(classes[0], mediaManager.queryVideoKlasses(classes[0]))
if(vl != null){
val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name })
classesMap[classes[0]]?.addAll(r)
}
}
fun setTabIndex(index: Int)
{
viewModelScope.launch()
{
_tabIndex.intValue = index;
if(updatingMap[index] == true) return@launch
updatingMap[index] = true
val vl = mediaManager.queryVideoBulk(classes[index], mediaManager.queryVideoKlasses(classes[index]))
if(vl != null){
val r = vl.sortedWith(compareBy(naturalOrder()) { it.video.name })
classesMap[classes[index]]?.addAll(r)
}
}
}
fun download(video :Video)
{
fetchManager.startVideoDownload(video)
}
init {
viewModelScope.launch {
viewModelScope.launch(Dispatchers.IO) {
init()
}
}