From 5b770a965de9ba0df6a9057ecef7be5ed7950cde Mon Sep 17 00:00:00 2001 From: acite <1498045907@qq.com> Date: Sat, 27 Sep 2025 00:37:57 +0800 Subject: [PATCH] [feat&optimize] Group Batch Download, Optimize download logic --- ...adUiModel.kt => VideoDownloadItemState.kt} | 5 +- .../acitelight/aether/service/FetchManager.kt | 135 ++++++++------- .../com/acitelight/aether/view/HomeScreen.kt | 11 +- .../aether/view/TransmissionScreen.kt | 162 ++++++++++++------ .../aether/viewModel/HomeScreenViewModel.kt | 4 +- .../viewModel/TransmissionScreenViewModel.kt | 126 ++++++++++---- .../aether/viewModel/VideoPlayerViewModel.kt | 7 +- 7 files changed, 304 insertions(+), 146 deletions(-) rename app/src/main/java/com/acitelight/aether/model/{DownloadUiModel.kt => VideoDownloadItemState.kt} (91%) diff --git a/app/src/main/java/com/acitelight/aether/model/DownloadUiModel.kt b/app/src/main/java/com/acitelight/aether/model/VideoDownloadItemState.kt similarity index 91% rename from app/src/main/java/com/acitelight/aether/model/DownloadUiModel.kt rename to app/src/main/java/com/acitelight/aether/model/VideoDownloadItemState.kt index eb0ce96..87378dc 100644 --- a/app/src/main/java/com/acitelight/aether/model/DownloadUiModel.kt +++ b/app/src/main/java/com/acitelight/aether/model/VideoDownloadItemState.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.tonyodev.fetch2.Status -class DownloadItemState( +class VideoDownloadItemState( val id: Int, fileName: String, filePath: String, @@ -15,7 +15,8 @@ class DownloadItemState( downloadedBytes: Long, totalBytes: Long, klass: String, - vid: String + vid: String, + val type: String ) { var fileName by mutableStateOf(fileName) var filePath by mutableStateOf(filePath) 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 9603580..c1a0d81 100644 --- a/app/src/main/java/com/acitelight/aether/service/FetchManager.kt +++ b/app/src/main/java/com/acitelight/aether/service/FetchManager.kt @@ -38,8 +38,7 @@ class FetchManager @Inject constructor( private var client: OkHttpClient? = null val configured = MutableStateFlow(false) - fun init() - { + fun init() { client = createOkHttp() val fetchConfiguration = FetchConfiguration.Builder(context) .setDownloadConcurrentLimit(8) @@ -72,8 +71,7 @@ class FetchManager @Inject constructor( fetch?.getDownloads { list -> callback(list) } ?: callback(emptyList()) } - suspend fun getAllDownloadsAsync(): List - { + suspend fun getAllDownloadsAsync(): List { configured.filter { it }.first() val completed = MutableStateFlow(false) var r = listOf() @@ -106,71 +104,88 @@ class FetchManager @Inject constructor( } ?: callback?.invoke() } - private suspend 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 + ) { configured.filter { it }.first() fetch?.enqueue(request, { r -> onEnqueued?.invoke(r) }, { e -> onError?.invoke(e) }) } - private fun getVideosDirectory() { + private fun makeFolder(video: Video) { val appFilesDir = context.getExternalFilesDir(null) - val videosDir = File(appFilesDir, "videos") - - if (!videosDir.exists()) { - val created = videosDir.mkdirs() - } + val videosDir = File(appFilesDir, "videos/${video.klass}/${video.id}/gallery") + videosDir.mkdirs() } - 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")) + suspend fun startVideoDownload(video: Video) { + makeFolder(video) + File( + context.getExternalFilesDir(null), + "videos/${video.klass}/${video.id}/summary.json" + ).writeText(Json.encodeToString(video)) + + val videoPath = + File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/video.mp4") + val coverPath = + File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/cover.jpg") + val subtitlePath = File( + context.getExternalFilesDir(null), + "videos/${video.klass}/${video.id}/subtitle.vtt" + ) + + val requests = mutableListOf( + Request(video.getVideo(), videoPath.path).apply { + extras = Extras( + mapOf( + "name" to video.video.name, + "id" to video.id, + "class" to video.klass, + "type" to "main" + ) + ) + }, + Request(video.getCover(), coverPath.path).apply { + extras = Extras( + mapOf( + "name" to video.video.name, + "id" to video.id, + "class" to video.klass, + "type" to "cover" + ) + ) + }, + Request(video.getSubtitle(), subtitlePath.path).apply { + extras = Extras( + mapOf( + "name" to video.video.name, + "id" to video.id, + "class" to video.klass, + "type" to "subtitle" + ) + ) + }, + ) + for (p in video.getGallery()) { + requests.add( + Request(p.url, File( + context.getExternalFilesDir(null), + "videos/${video.klass}/${video.id}/gallery/${p.name}" + ).path).apply { + extras = Extras( + mapOf( + "name" to video.video.name, + "id" to video.id, + "class" to video.klass, + "type" to "gallery" + ) + ) } - - 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.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")) - - downloadFile( - client!!, - video.getSubtitle(), - File(context.getExternalFilesDir(null), "videos/${video.klass}/${video.id}/subtitle.vtt")) - - 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}")) - } + for (i in requests) + enqueue(i) } } \ No newline at end of file diff --git a/app/src/main/java/com/acitelight/aether/view/HomeScreen.kt b/app/src/main/java/com/acitelight/aether/view/HomeScreen.kt index 0e6e1b4..1a5cd6d 100644 --- a/app/src/main/java/com/acitelight/aether/view/HomeScreen.kt +++ b/app/src/main/java/com/acitelight/aether/view/HomeScreen.kt @@ -81,7 +81,16 @@ fun HomeScreen( i, { updateRelate(homeScreenViewModel.recentManager.recentVideo, i) - val route = "video_player_route/${ "${i.klass}/${i.id}".toHex() }" + + val playList = mutableListOf("${i.klass}/${i.id}") + val fv = homeScreenViewModel.videoLibrary.classesMap.map { it.value }.flatten() + + val group = fv.filter { it.klass == i.klass && it.video.group == i.video.group } + for (i in group) { + playList.add("${i.klass}/${i.id}") + } + + val route = "video_player_route/${playList.joinToString(",").toHex()}" navController.navigate(route) }, homeScreenViewModel.imageLoader!!) HorizontalDivider(Modifier.padding(vertical = 8.dp).alpha(0.4f), 1.dp, DividerDefaults.color) diff --git a/app/src/main/java/com/acitelight/aether/view/TransmissionScreen.kt b/app/src/main/java/com/acitelight/aether/view/TransmissionScreen.kt index f20fa3f..6311cbb 100644 --- a/app/src/main/java/com/acitelight/aether/view/TransmissionScreen.kt +++ b/app/src/main/java/com/acitelight/aether/view/TransmissionScreen.kt @@ -6,8 +6,8 @@ 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.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -16,24 +16,17 @@ 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 -import androidx.compose.material.icons.* import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CardElevation -import androidx.compose.material3.DividerDefaults -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProgressIndicatorDefaults -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -45,17 +38,13 @@ 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.VideoDownloadItemState 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 @@ -63,21 +52,45 @@ import kotlinx.serialization.json.Json import java.io.File @Composable -fun TransmissionScreen(navigator: NavHostController, transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel()) { +fun TransmissionScreen( + navigator: NavHostController, + transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel() +) { val downloads = transmissionScreenViewModel.downloads + LazyColumn( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(downloads, key = { it.id }) { item -> - DownloadCard( + items(downloads.filter { it.type == "main" }, key = { it.id }) { item -> + VideoDownloadCard( navigator = navigator, viewModel = transmissionScreenViewModel, model = item, - onPause = { transmissionScreenViewModel.pause(item.id) }, - onResume = { transmissionScreenViewModel.resume(item.id) }, - onCancel = { transmissionScreenViewModel.cancel(item.id) }, - onDelete = { transmissionScreenViewModel.delete(item.id, true) } + onPause = { + for (i in downloadToGroup( + item, + downloads + )) transmissionScreenViewModel.pause(i.id) + }, + onResume = { + for (i in downloadToGroup( + item, + downloads + )) transmissionScreenViewModel.resume(i.id) + }, + onCancel = { + for (i in downloadToGroup( + item, + downloads + )) transmissionScreenViewModel.cancel(i.id) + }, + onDelete = { + for (i in downloadToGroup( + item, + downloads + )) transmissionScreenViewModel.delete(i.id, true) + } ) } } @@ -85,10 +98,10 @@ fun TransmissionScreen(navigator: NavHostController, transmissionScreenViewModel @Composable -private fun DownloadCard( +private fun VideoDownloadCard( navigator: NavHostController, viewModel: TransmissionScreenViewModel, - model: DownloadItemState, + model: VideoDownloadItemState, onPause: () -> Unit, onResume: () -> Unit, onCancel: () -> Unit, @@ -102,24 +115,50 @@ private fun DownloadCard( .padding(8.dp) .background(Color.Transparent) .clickable(onClick = { - if(model.status == Status.COMPLETED) - { + 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" + 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