[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) { composable(Screen.Transmission.route) {
CardPage(title = "Tasks") { CardPage(title = "Tasks") {
TransmissionScreen() TransmissionScreen(navigator = navController)
} }
} }
composable(Screen.Me.route) { composable(Screen.Me.route) {
@@ -236,6 +236,8 @@ fun BottomNavigationBar(navController: NavController) {
Screen.Transmission, Screen.Transmission,
Screen.Me Screen.Me
) else listOf( ) else listOf(
Screen.Video,
Screen.Transmission,
Screen.Me Screen.Me
) )

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

@@ -13,7 +13,9 @@ class DownloadItemState(
progress: Int, progress: Int,
status: Status, status: Status,
downloadedBytes: Long, downloadedBytes: Long,
totalBytes: Long totalBytes: Long,
klass: String,
vid: String
) { ) {
var fileName by mutableStateOf(fileName) var fileName by mutableStateOf(fileName)
var filePath by mutableStateOf(filePath) var filePath by mutableStateOf(filePath)
@@ -22,4 +24,7 @@ class DownloadItemState(
var status by mutableStateOf(status) var status by mutableStateOf(status)
var downloadedBytes by mutableStateOf(downloadedBytes) var downloadedBytes by mutableStateOf(downloadedBytes)
var totalBytes by mutableStateOf(totalBytes) 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 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,30 +1,59 @@
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 isLocal: Boolean,
val localBase: String,
val klass: String, val klass: String,
val id: String, val id: String,
val token: String, val token: String,
val video: VideoResponse val video: VideoResponse
) { ) {
fun getCover(): String fun getCover(): String {
{ return if (isLocal)
return "${ApiClient.getBase()}api/video/$klass/$id/cover?token=$token" "$localBase/videos/$klass/$id/cover.jpg"
else
"${ApiClient.getBase()}api/video/$klass/$id/cover?token=$token"
} }
fun getVideo(): String fun getVideo(): String {
{ return if (isLocal)
return "${ApiClient.getBase()}api/video/$klass/$id/av?token=$token" "$localBase/videos/$klass/$id/video.mp4"
else
"${ApiClient.getBase()}api/video/$klass/$id/av?token=$token"
} }
fun getGallery(): List<KeyImage> fun getGallery(): List<KeyImage> {
{ return if (isLocal)
return video.gallery.map{ video.gallery.map {
KeyImage(url = "${ApiClient.getBase()}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it") 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 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,

View File

@@ -105,9 +105,8 @@ class AbyssTunnelProxy @Inject constructor(
val read = localIn.read(buffer) val read = localIn.read(buffer)
if (read <= 0) if (read <= 0)
break break
Log.i("Delay Analyze", "Read $read Bytes from HttpClient")
abyss.write(buffer, 0, read) 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) val n = abyss.read(buffer, 0, buffer.size)
if (n <= 0) if (n <= 0)
break break
Log.i("Delay Analyze", "Read $n Bytes from Remote Abyss")
localOut.write(buffer, 0, n) 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 ?: "" domain = selectedUrl.toHttpUrlOrNull()?.host ?: ""
cert = crt cert = crt
base = selectedUrl base = selectedUrl
withContext(Dispatchers.IO) withContext(Dispatchers.IO)
{ {
(context as AetherApp).abyssService?.proxy?.config(base.toUri().host!!, 4096) (context as AetherApp).abyssService?.proxy?.config(ApiClient.getBase().toUri().host!!, 4096)
context.abyssService?.downloader?.init()
} }
api = createRetrofit().create(ApiInterface::class.java) api = createRetrofit().create(ApiInterface::class.java)
Log.i("Delay Analyze", "Start Abyss Hello") Log.i("Delay Analyze", "Start Abyss Hello")

View File

@@ -1,6 +1,7 @@
package com.acitelight.aether.service package com.acitelight.aether.service
import android.content.Context import android.content.Context
import androidx.compose.runtime.mutableStateOf
import com.acitelight.aether.Screen import com.acitelight.aether.Screen
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.createOkHttp
@@ -13,7 +14,18 @@ import com.tonyodev.fetch2.Status
import com.tonyodev.fetch2core.Extras import com.tonyodev.fetch2core.Extras
import com.tonyodev.fetch2okhttp.OkHttpDownloader import com.tonyodev.fetch2okhttp.OkHttpDownloader
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.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.File
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -23,21 +35,25 @@ class FetchManager @Inject constructor(
) { ) {
private var fetch: Fetch? = null private var fetch: Fetch? = null
private var listener: FetchListener? = null private var listener: FetchListener? = null
private var client: OkHttpClient? = null
val configured = MutableStateFlow(false)
fun init() fun init()
{ {
client = createOkHttp()
val fetchConfiguration = FetchConfiguration.Builder(context) val fetchConfiguration = FetchConfiguration.Builder(context)
.setDownloadConcurrentLimit(8) .setDownloadConcurrentLimit(8)
.setHttpDownloader(OkHttpDownloader(createOkHttp())) .setHttpDownloader(OkHttpDownloader(client))
.build() .build()
fetch = Fetch.Impl.getInstance(fetchConfiguration) fetch = Fetch.Impl.getInstance(fetchConfiguration)
configured.update { true }
} }
// listener management // listener management
fun setListener(l: FetchListener) { suspend fun setListener(l: FetchListener) {
if (fetch == null) configured.filter { it }.first()
return
listener?.let { fetch?.removeListener(it) } listener?.let { fetch?.removeListener(it) }
listener = l listener = l
fetch?.addListener(l) fetch?.addListener(l)
@@ -51,14 +67,24 @@ class FetchManager @Inject constructor(
} }
// query downloads // query downloads
fun getAllDownloads(callback: (List<Download>) -> Unit) { suspend fun getAllDownloads(callback: (List<Download>) -> Unit) {
if (fetch == null) init() configured.filter { it }.first()
fetch?.getDownloads { list -> callback(list) } ?: callback(emptyList()) fetch?.getDownloads { list -> callback(list) } ?: callback(emptyList())
} }
fun getDownloadsByStatus(status: Status, callback: (List<Download>) -> Unit) { suspend fun getAllDownloadsAsync(): List<Download>
if (fetch == null) init() {
fetch?.getDownloadsWithStatus(status) { list -> callback(list) } ?: callback(emptyList()) 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 // operations
@@ -80,13 +106,13 @@ class FetchManager @Inject constructor(
} ?: callback?.invoke() } ?: callback?.invoke()
} }
private fun enqueue(request: Request, onEnqueued: ((Request) -> Unit)? = null, onError: ((com.tonyodev.fetch2.Error) -> Unit)? = null) { private suspend fun enqueue(request: Request, onEnqueued: ((Request) -> Unit)? = null, onError: ((com.tonyodev.fetch2.Error) -> Unit)? = null) {
if (fetch == null) init() configured.filter { it }.first()
fetch?.enqueue(request, { r -> onEnqueued?.invoke(r) }, { e -> onError?.invoke(e) }) fetch?.enqueue(request, { r -> onEnqueued?.invoke(r) }, { e -> onError?.invoke(e) })
} }
private fun getVideosDirectory() { private fun getVideosDirectory() {
val appFilesDir = context.filesDir val appFilesDir = context.getExternalFilesDir(null)
val videosDir = File(appFilesDir, "videos") val videosDir = File(appFilesDir, "videos")
if (!videosDir.exists()) { 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 { val request = Request(video.getVideo(), path.path).apply {
extras = Extras(mapOf("name" to video.video.name, "id" to video.id, "class" to video.klass)) 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) 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.Comic
import com.acitelight.aether.model.ComicResponse import com.acitelight.aether.model.ComicResponse
import com.acitelight.aether.model.Video import com.acitelight.aether.model.Video
import com.tonyodev.fetch2.Status
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class MediaManager @Inject constructor( class MediaManager @Inject constructor(
val fetchManager: FetchManager,
@ApplicationContext val context: Context
) )
{ {
var token: String = "null" var token: String = "null"
@@ -43,23 +47,86 @@ class MediaManager @Inject constructor(
suspend fun queryVideo(klass: String, id: String): Video? suspend fun queryVideo(klass: String, id: String): Video?
{ {
val downloaded = fetchManager.getAllDownloadsAsync().filter {
it.status == Status.COMPLETED &&
it.extras.getString("id", "") == id &&
it.extras.getString("class", "") == klass
}
if(!downloaded.isEmpty())
{
val jsonString = File(
context.getExternalFilesDir(null),
"videos/$klass/$id/summary.json"
).readText()
return Json.decodeFromString<Video>(jsonString).toLocal(context.getExternalFilesDir(null)?.path!!)
}
try { try {
val j = ApiClient.api!!.queryVideo(klass, id, token) val j = ApiClient.api!!.queryVideo(klass, id, token)
return Video(klass = klass, id = id, token=token, isLocal = false, video = j) return Video(klass = klass, id = id, token=token, isLocal = false, localBase = "", video = j)
}catch (e: Exception) }catch (e: Exception)
{ {
return null 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 { try {
val j = ApiClient.api!!.queryVideoBulk(klass, id, token) val jsonString = localFile.readText()
return j.zip(id).map {Video(klass = klass, id = it.second, token=token, isLocal = false, video = it.first)} Json.decodeFromString<Video>(jsonString).toLocal(
}catch (e: Exception) context.getExternalFilesDir(null)?.path ?: ""
{ )
return null } 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.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) 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 import com.acitelight.aether.viewModel.ComicGridViewModel
@Composable @Composable
fun ComicGridView(comicId: String, navController: NavHostController, comicGridViewModel: ComicGridViewModel = viewModel()) { fun ComicGridView(comicId: String, navController: NavHostController, comicGridViewModel: ComicGridViewModel = hiltViewModel<ComicGridViewModel>()) {
comicGridViewModel.SetupClient()
comicGridViewModel.resolve(comicId.hexToString()) comicGridViewModel.resolve(comicId.hexToString())
comicGridViewModel.updateProcess(comicId.hexToString()){} comicGridViewModel.updateProcess(comicId.hexToString()){}
ToggleFullScreen(false) ToggleFullScreen(false)
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val context = LocalContext.current
val comic by comicGridViewModel.comic val comic by comicGridViewModel.comic
val record by comicGridViewModel.record val record by comicGridViewModel.record

View File

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

View File

@@ -1,13 +1,18 @@
package com.acitelight.aether.view package com.acitelight.aether.view
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Stop 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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.DownloadItemState
import com.acitelight.aether.model.Video
import com.acitelight.aether.viewModel.TransmissionScreenViewModel import com.acitelight.aether.viewModel.TransmissionScreenViewModel
import com.tonyodev.fetch2.Download import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.FetchListener import com.tonyodev.fetch2.FetchListener
import com.tonyodev.fetch2.Status import com.tonyodev.fetch2.Status
import com.tonyodev.fetch2core.DownloadBlock 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 @Composable
fun TransmissionScreen(transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel<TransmissionScreenViewModel>()) fun TransmissionScreen(navigator: NavHostController, transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel<TransmissionScreenViewModel>()) {
{
val downloads = transmissionScreenViewModel.downloads val downloads = transmissionScreenViewModel.downloads
LazyColumn(
Surface(modifier = Modifier.fillMaxWidth()) { modifier = Modifier.fillMaxWidth(),
LazyColumn(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(downloads, key = { it.id }) { item -> items(downloads, key = { it.id }) { item ->
DownloadCard( DownloadCard(
navigator = navigator,
viewModel = transmissionScreenViewModel,
model = item, model = item,
onPause = { transmissionScreenViewModel.pause(item.id) }, onPause = { transmissionScreenViewModel.pause(item.id) },
onResume = { transmissionScreenViewModel.resume(item.id) }, onResume = { transmissionScreenViewModel.resume(item.id) },
@@ -59,11 +82,12 @@ fun TransmissionScreen(transmissionScreenViewModel: TransmissionScreenViewModel
} }
} }
} }
}
@Composable @Composable
private fun DownloadCard( private fun DownloadCard(
navigator: NavHostController,
viewModel: TransmissionScreenViewModel,
model: DownloadItemState, model: DownloadItemState,
onPause: () -> Unit, onPause: () -> Unit,
onResume: () -> Unit, onResume: () -> Unit,
@@ -71,24 +95,100 @@ private fun DownloadCard(
onDelete: () -> Unit onDelete: () -> Unit
) { ) {
Card( Card(
shape = RoundedCornerShape(8.dp),
elevation = CardDefaults.cardElevation(4.dp), elevation = CardDefaults.cardElevation(4.dp),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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() .fillMaxWidth()
.padding(12.dp) .padding(12.dp)
) { ) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(text = model.fileName, style = MaterialTheme.typography.titleMedium) Text(text = model.fileName, style = MaterialTheme.typography.titleMedium)
} }
// 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 // progress bar
LinearProgressIndicator( LinearProgressIndicator(
progress = { model.progress.coerceIn(0, 100) / 100f }, progress = { model.progress.coerceIn(0, 100) / 100f },
@@ -117,9 +217,13 @@ private fun DownloadCard(
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp)) Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
} }
} }
Status.PAUSED, Status.QUEUED -> { Status.PAUSED, Status.QUEUED -> {
Button(onClick = onResume) { 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)) Text(text = " Resume", modifier = Modifier.padding(start = 6.dp))
} }
Button(onClick = onCancel) { Button(onClick = onCancel) {
@@ -127,16 +231,21 @@ private fun DownloadCard(
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp)) Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
} }
} }
Status.COMPLETED -> { Status.COMPLETED -> {
Button(onClick = onDelete) { Button(onClick = onDelete) {
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete") Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
Text(text = " Delete", modifier = Modifier.padding(start = 6.dp)) Text(text = " Delete", modifier = Modifier.padding(start = 6.dp))
} }
} }
else -> { else -> {
// for FAILED, CANCELLED, REMOVED etc. // for FAILED, CANCELLED, REMOVED etc.
Button(onClick = onResume) { 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)) Text(text = " Retry", modifier = Modifier.padding(start = 6.dp))
} }
Button(onClick = onDelete) { 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.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
@@ -71,13 +72,14 @@ fun String.hexToString(charset: Charset = Charsets.UTF_8): String {
} }
@Composable @Composable
fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = hiltViewModel<VideoScreenViewModel>(), navController: NavHostController) fun VideoScreen(
{ videoScreenViewModel: VideoScreenViewModel = hiltViewModel<VideoScreenViewModel>(),
navController: NavHostController
) {
val tabIndex by videoScreenViewModel.tabIndex; val tabIndex by videoScreenViewModel.tabIndex;
videoScreenViewModel.SetupClient()
Column( Column(
modifier = Modifier.fillMaxSize() // 或至少 fillMaxWidth() modifier = Modifier.fillMaxSize()
) { ) {
TopRow(videoScreenViewModel); TopRow(videoScreenViewModel);
@@ -88,9 +90,11 @@ fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = hiltViewModel<Video
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) )
{ {
if(videoScreenViewModel.classes.isNotEmpty()) if (videoScreenViewModel.videoLibrary.classes.isNotEmpty()) {
{ items(
items(videoScreenViewModel.classesMap[videoScreenViewModel.classes[tabIndex]] ?: mutableStateListOf()) { video -> videoScreenViewModel.videoLibrary.classesMap[videoScreenViewModel.videoLibrary.classes[tabIndex]]
?: mutableStateListOf()
) { video ->
VideoCard(video, navController, videoScreenViewModel) VideoCard(video, navController, videoScreenViewModel)
} }
} }
@@ -100,14 +104,16 @@ fun VideoScreen(videoScreenViewModel: VideoScreenViewModel = hiltViewModel<Video
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TopRow(videoScreenViewModel: VideoScreenViewModel) fun TopRow(videoScreenViewModel: VideoScreenViewModel) {
{
val tabIndex by videoScreenViewModel.tabIndex; val tabIndex by videoScreenViewModel.tabIndex;
if(videoScreenViewModel.classes.isEmpty()) return if (videoScreenViewModel.videoLibrary.classes.isEmpty()) return
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
ScrollableTabRow (selectedTabIndex = tabIndex, modifier = Modifier.background(colorScheme.surface)) { ScrollableTabRow(
videoScreenViewModel.classes.forEachIndexed { index, title -> selectedTabIndex = tabIndex,
modifier = Modifier.background(colorScheme.surface)
) {
videoScreenViewModel.videoLibrary.classes.forEachIndexed { index, title ->
Tab( Tab(
selected = tabIndex == index, selected = tabIndex == index,
onClick = { videoScreenViewModel.setTabIndex(index) }, onClick = { videoScreenViewModel.setTabIndex(index) },
@@ -118,7 +124,11 @@ fun TopRow(videoScreenViewModel: VideoScreenViewModel)
} }
@Composable @Composable
fun VideoCard(video: Video, navController: NavHostController, videoScreenViewModel: VideoScreenViewModel) { fun VideoCard(
video: Video,
navController: NavHostController,
videoScreenViewModel: VideoScreenViewModel
) {
val tabIndex by videoScreenViewModel.tabIndex; val tabIndex by videoScreenViewModel.tabIndex;
Card( Card(
modifier = Modifier modifier = Modifier
@@ -126,7 +136,10 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod
.wrapContentHeight() .wrapContentHeight()
.combinedClickable( .combinedClickable(
onClick = { 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()}" val route = "video_player_route/${"${video.klass}/${video.id}".toHex()}"
navController.navigate(route) navController.navigate(route)
}, },
@@ -134,7 +147,11 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod
videoScreenViewModel.viewModelScope.launch { videoScreenViewModel.viewModelScope.launch {
videoScreenViewModel.download(video) 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), shape = RoundedCornerShape(6.dp),
@@ -144,6 +161,7 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod
.fillMaxWidth() .fillMaxWidth()
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
AsyncImage( AsyncImage(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover()) .data(video.getCover())
@@ -158,27 +176,54 @@ fun VideoCard(video: Video, navController: NavHostController, videoScreenViewMod
) )
Text( Text(
modifier = Modifier.align(Alignment.BottomEnd).padding(2.dp), modifier = Modifier
text = formatTime(video.video.duration), fontSize = 12.sp, fontWeight = FontWeight.Bold) .align(Alignment.BottomEnd)
.padding(2.dp),
text = formatTime(video.video.duration),
fontSize = 12.sp,
fontWeight = FontWeight.Bold
)
Box( Box(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(24.dp) .height(24.dp)
.background( brush = Brush.verticalGradient( .background(
brush = Brush.verticalGradient(
colors = listOf( colors = listOf(
Color.Transparent, Color.Transparent,
Color.Black.copy(alpha = 0.45f) 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(
text = video.video.name, text = video.video.name,
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
maxLines = 2, 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)) Spacer(modifier = Modifier.weight(1f))
Row( Row(

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,28 @@
package com.acitelight.aether.viewModel package com.acitelight.aether.viewModel
import android.content.Context
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.acitelight.aether.model.DownloadItemState import com.acitelight.aether.model.DownloadItemState
import com.acitelight.aether.service.FetchManager import com.acitelight.aether.service.FetchManager
import com.acitelight.aether.service.VideoLibrary
import com.tonyodev.fetch2.Download import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.FetchListener import com.tonyodev.fetch2.FetchListener
import com.tonyodev.fetch2core.DownloadBlock import com.tonyodev.fetch2core.DownloadBlock
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TransmissionScreenViewModel @Inject constructor( class TransmissionScreenViewModel @Inject constructor(
private val fetchManager: FetchManager val fetchManager: FetchManager,
@ApplicationContext val context: Context,
private val videoLibrary: VideoLibrary
) : ViewModel() { ) : ViewModel() {
private val _downloads: SnapshotStateList<DownloadItemState> = mutableStateListOf() private val _downloads: SnapshotStateList<DownloadItemState> = mutableStateListOf()
val downloads: SnapshotStateList<DownloadItemState> = _downloads 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 onProgress(download: Download, etaInMilliSeconds: Long, downloadedBytesPerSecond: Long) { handleUpsert(download) }
override fun onPaused(download: Download) { handleUpsert(download) } override fun onPaused(download: Download) { handleUpsert(download) }
override fun onResumed(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 onCancelled(download: Download) { handleUpsert(download) }
override fun onRemoved(download: Download) { handleRemove(download.id) } override fun onRemoved(download: Download) { handleRemove(download.id) }
override fun onDeleted(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( override fun onStarted(
download: Download, download: Download,
downloadBlocks: List<DownloadBlock>, downloadBlocks: List<DownloadBlock>,
@@ -107,7 +117,9 @@ class TransmissionScreenViewModel @Inject constructor(
progress = download.progress, progress = download.progress,
status = download.status, status = download.status,
downloadedBytes = download.downloaded, 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 { init {
viewModelScope.launch {
fetchManager.setListener(fetchListener) fetchManager.setListener(fetchListener)
viewModelScope.launch(Dispatchers.Main) { withContext(Dispatchers.Main) {
fetchManager.getAllDownloads { list -> fetchManager.getAllDownloads { list ->
_downloads.clear() _downloads.clear()
idToState.clear() idToState.clear()
list.sortedByDescending { it.id }.forEach { d -> list.sortedBy { it.extras.getString("name", "") }.forEach { d ->
val s = downloadToState(d) val s = downloadToState(d)
_downloads.add(s) _downloads.add(s)
idToState[s.id] = s idToState[s.id] = s
@@ -142,3 +155,4 @@ class TransmissionScreenViewModel @Inject constructor(
} }
} }
} }
}

View File

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

View File

@@ -13,86 +13,106 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil3.ImageLoader import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.Global
import com.acitelight.aether.model.Video import com.acitelight.aether.model.Video
import com.acitelight.aether.service.ApiClient.createOkHttp import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.FetchManager import com.acitelight.aether.service.FetchManager
import com.acitelight.aether.service.MediaManager import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.RecentManager import com.acitelight.aether.service.RecentManager
import com.acitelight.aether.service.VideoLibrary
import com.tonyodev.fetch2.Status
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@HiltViewModel @HiltViewModel
class VideoScreenViewModel @Inject constructor( class VideoScreenViewModel @Inject constructor(
private val fetchManager: FetchManager, private val fetchManager: FetchManager,
@ApplicationContext val context: Context, @ApplicationContext val context: Context,
val mediaManager: MediaManager, val mediaManager: MediaManager,
val recentManager: RecentManager val recentManager: RecentManager,
) : ViewModel() val videoLibrary: VideoLibrary
{ ) : ViewModel() {
private val _tabIndex = mutableIntStateOf(0) private val _tabIndex = mutableIntStateOf(0)
val tabIndex: State<Int> = _tabIndex val tabIndex: State<Int> = _tabIndex
// val videos = mutableStateListOf<Video>()
var classes = mutableStateListOf<String>()
val classesMap = mutableStateMapOf<String, SnapshotStateList<Video>>()
var imageLoader: ImageLoader? = null; var imageLoader: ImageLoader? = null;
val updatingMap: MutableMap<Int, Boolean> = mutableMapOf()
@Composable suspend fun init() {
fun SetupClient() 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) imageLoader = ImageLoader.Builder(context)
.components { .components {
add(OkHttpNetworkFetcherFactory(createOkHttp())) add(OkHttpNetworkFetcherFactory(createOkHttp()))
} }
.build() .build()
}
suspend fun init() { viewModelScope.launch(Dispatchers.IO) {
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 {
init() init()
} }
} }