From e94249aa8fcfee5877e6571f50c2df2179fa0bbb Mon Sep 17 00:00:00 2001 From: acite <1498045907@qq.com> Date: Mon, 15 Sep 2025 03:15:43 +0800 Subject: [PATCH] [feat] Complete video caching system --- .../com/acitelight/aether/MainActivity.kt | 4 +- .../com/acitelight/aether/model/Comment.kt | 4 + .../aether/model/DownloadUiModel.kt | 7 +- .../com/acitelight/aether/model/KeyImage.kt | 1 + .../java/com/acitelight/aether/model/Video.kt | 53 ++++-- .../acitelight/aether/model/VideoResponse.kt | 3 + .../aether/service/AbyssTunnelProxy.kt | 5 +- .../acitelight/aether/service/ApiClient.kt | 5 +- .../acitelight/aether/service/FetchManager.kt | 96 ++++++++-- .../acitelight/aether/service/MediaManager.kt | 87 +++++++-- .../aether/service/RecentManager.kt | 2 +- .../acitelight/aether/service/VideoLibrary.kt | 20 +++ .../acitelight/aether/view/ComicGridView.kt | 4 +- .../com/acitelight/aether/view/ComicScreen.kt | 1 - .../aether/view/TransmissionScreen.kt | 169 ++++++++++++++---- .../com/acitelight/aether/view/VideoScreen.kt | 103 ++++++++--- .../aether/viewModel/ComicGridViewModel.kt | 12 +- .../aether/viewModel/ComicScreenViewModel.kt | 10 +- .../aether/viewModel/MeScreenViewModel.kt | 25 ++- .../viewModel/TransmissionScreenViewModel.kt | 46 +++-- .../aether/viewModel/VideoPlayerViewModel.kt | 65 ++++--- .../aether/viewModel/VideoScreenViewModel.kt | 102 ++++++----- 22 files changed, 613 insertions(+), 211 deletions(-) create mode 100644 app/src/main/java/com/acitelight/aether/service/VideoLibrary.kt diff --git a/app/src/main/java/com/acitelight/aether/MainActivity.kt b/app/src/main/java/com/acitelight/aether/MainActivity.kt index 4434af3..8975207 100644 --- a/app/src/main/java/com/acitelight/aether/MainActivity.kt +++ b/app/src/main/java/com/acitelight/aether/MainActivity.kt @@ -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 ) diff --git a/app/src/main/java/com/acitelight/aether/model/Comment.kt b/app/src/main/java/com/acitelight/aether/model/Comment.kt index 16f6995..a8199d6 100644 --- a/app/src/main/java/com/acitelight/aether/model/Comment.kt +++ b/app/src/main/java/com/acitelight/aether/model/Comment.kt @@ -1,5 +1,9 @@ package com.acitelight.aether.model +import kotlinx.serialization.Serializable + + +@Serializable data class Comment( val content: String, val username: String, diff --git a/app/src/main/java/com/acitelight/aether/model/DownloadUiModel.kt b/app/src/main/java/com/acitelight/aether/model/DownloadUiModel.kt index 6b0a125..eb0ce96 100644 --- a/app/src/main/java/com/acitelight/aether/model/DownloadUiModel.kt +++ b/app/src/main/java/com/acitelight/aether/model/DownloadUiModel.kt @@ -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) } \ No newline at end of file diff --git a/app/src/main/java/com/acitelight/aether/model/KeyImage.kt b/app/src/main/java/com/acitelight/aether/model/KeyImage.kt index 4754211..ce83a54 100644 --- a/app/src/main/java/com/acitelight/aether/model/KeyImage.kt +++ b/app/src/main/java/com/acitelight/aether/model/KeyImage.kt @@ -1,6 +1,7 @@ package com.acitelight.aether.model data class KeyImage( + val name: String, val url: String, val key: String ) diff --git a/app/src/main/java/com/acitelight/aether/model/Video.kt b/app/src/main/java/com/acitelight/aether/model/Video.kt index 25a38ae..d389fde 100644 --- a/app/src/main/java/com/acitelight/aether/model/Video.kt +++ b/app/src/main/java/com/acitelight/aether/model/Video.kt @@ -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 - { - return video.gallery.map{ - KeyImage(url = "${ApiClient.getBase()}api/video/$klass/$id/gallery/$it?token=$token", key = "$klass/$id/gallery/$it") + fun getGallery(): List { + 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 + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/acitelight/aether/model/VideoResponse.kt b/app/src/main/java/com/acitelight/aether/model/VideoResponse.kt index bc15631..fc7bfec 100644 --- a/app/src/main/java/com/acitelight/aether/model/VideoResponse.kt +++ b/app/src/main/java/com/acitelight/aether/model/VideoResponse.kt @@ -1,5 +1,8 @@ package com.acitelight.aether.model +import kotlinx.serialization.Serializable + +@Serializable data class VideoResponse( val name: String, val duration: Long, diff --git a/app/src/main/java/com/acitelight/aether/service/AbyssTunnelProxy.kt b/app/src/main/java/com/acitelight/aether/service/AbyssTunnelProxy.kt index 03539ca..0d55689 100644 --- a/app/src/main/java/com/acitelight/aether/service/AbyssTunnelProxy.kt +++ b/app/src/main/java/com/acitelight/aether/service/AbyssTunnelProxy.kt @@ -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") } } } \ No newline at end of file diff --git a/app/src/main/java/com/acitelight/aether/service/ApiClient.kt b/app/src/main/java/com/acitelight/aether/service/ApiClient.kt index ae2691b..925f8cd 100644 --- a/app/src/main/java/com/acitelight/aether/service/ApiClient.kt +++ b/app/src/main/java/com/acitelight/aether/service/ApiClient.kt @@ -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") diff --git a/app/src/main/java/com/acitelight/aether/service/FetchManager.kt b/app/src/main/java/com/acitelight/aether/service/FetchManager.kt index 0fb8801..771aa78 100644 --- a/app/src/main/java/com/acitelight/aether/service/FetchManager.kt +++ b/app/src/main/java/com/acitelight/aether/service/FetchManager.kt @@ -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) + 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) -> Unit) { - if (fetch == null) init() + suspend fun getAllDownloads(callback: (List) -> Unit) { + configured.filter { it }.first() fetch?.getDownloads { list -> callback(list) } ?: callback(emptyList()) } - fun getDownloadsByStatus(status: Status, callback: (List) -> Unit) { - if (fetch == null) init() - fetch?.getDownloadsWithStatus(status) { list -> callback(list) } ?: callback(emptyList()) + suspend fun getAllDownloadsAsync(): List + { + configured.filter { it }.first() + val completed = MutableStateFlow(false) + var r = listOf() + + 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 = 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}")) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/acitelight/aether/service/MediaManager.kt b/app/src/main/java/com/acitelight/aether/service/MediaManager.kt index b8ebd08..57b8117 100644 --- a/app/src/main/java/com/acitelight/aether/service/MediaManager.kt +++ b/app/src/main/java/com/acitelight/aether/service/MediaManager.kt @@ -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