[feat&optimize] Group Batch Download, Optimize download logic

This commit is contained in:
acite
2025-09-27 00:37:57 +08:00
parent a89f892306
commit 5b770a965d
7 changed files with 304 additions and 146 deletions

View File

@@ -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)

View File

@@ -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<Download>
{
suspend fun getAllDownloadsAsync(): List<Download> {
configured.filter { it }.first()
val completed = MutableStateFlow(false)
var r = listOf<Download>()
@@ -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<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"))
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)
}
}

View File

@@ -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)

View File

@@ -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<TransmissionScreenViewModel>()) {
fun TransmissionScreen(
navigator: NavHostController,
transmissionScreenViewModel: TransmissionScreenViewModel = hiltViewModel<TransmissionScreenViewModel>()
) {
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<Video>(it).toLocal(viewModel.context.getExternalFilesDir(null)!!.path) }
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 }
jsonQuery,
jsonQuery.first { it.id == model.vid && it.klass == model.klass }
)
val route = "video_player_route/${"${model.klass}/${model.vid}".toHex()}"
withContext(Dispatchers.Main){
val playList = mutableListOf("${model.klass}/${model.vid}")
val fv = viewModel.videoLibrary.classesMap.map { it.value }.flatten()
val video = fv.firstOrNull { it.klass == model.klass && it.id == model.vid }
if (video != null) {
val group = fv.filter { it.klass == video.klass && it.video.group == video.video.group }
for (i in group) {
playList.add("${i.klass}/${i.id}")
}
}
val route = "video_player_route/${playList.joinToString(",").toHex()}"
withContext(Dispatchers.Main) {
navigator.navigate(route)
}
}
@@ -137,32 +176,52 @@ private fun DownloadCard(
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = model.fileName, style = MaterialTheme.typography.titleMedium)
// Text(text = model.filePath, style = MaterialTheme.typography.titleSmall)
}
}
Box(Modifier
.fillMaxWidth()
.padding(top = 5.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"
val video = viewModel.modelToVideo(model)
if (video == null)
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
)
.memoryCacheKey("${model.klass}/${model.vid}/cover")
.diskCacheKey("${model.klass}/${model.vid}/cover")
.build(),
contentDescription = null,
modifier = Modifier.height(100.dp),
contentScale = ContentScale.Fit
)
else {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(video.getCover())
.memoryCacheKey("${model.klass}/${model.vid}/cover")
.diskCacheKey("${model.klass}/${model.vid}/cover")
.build(),
contentDescription = null,
modifier = Modifier.height(100.dp),
contentScale = ContentScale.Fit,
imageLoader = viewModel.imageLoader!!
)
}
}
Column(Modifier.align(Alignment.BottomEnd)) {
@@ -258,3 +317,10 @@ private fun DownloadCard(
}
}
}
fun downloadToGroup(
i: VideoDownloadItemState,
downloads: List<VideoDownloadItemState>
): List<VideoDownloadItemState> {
return downloads.filter { it.vid == i.vid && it.klass == i.klass }
}

View File

@@ -7,6 +7,7 @@ import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.RecentManager
import com.acitelight.aether.service.VideoLibrary
import kotlinx.coroutines.launch
import javax.inject.Inject
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -16,7 +17,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext
@HiltViewModel
class HomeScreenViewModel @Inject constructor(
val recentManager: RecentManager,
@ApplicationContext val context: Context
@ApplicationContext val context: Context,
val videoLibrary: VideoLibrary,
) : ViewModel()
{
var imageLoader: ImageLoader? = null

View File

@@ -5,7 +5,11 @@ 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 coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoDownloadItemState
import com.acitelight.aether.service.ApiClient.createOkHttp
import com.acitelight.aether.service.FetchManager
import com.acitelight.aether.service.VideoLibrary
import com.tonyodev.fetch2.Download
@@ -22,36 +26,82 @@ import javax.inject.Inject
class TransmissionScreenViewModel @Inject constructor(
val fetchManager: FetchManager,
@ApplicationContext val context: Context,
private val videoLibrary: VideoLibrary
val videoLibrary: VideoLibrary,
) : ViewModel() {
private val _downloads: SnapshotStateList<DownloadItemState> = mutableStateListOf()
val downloads: SnapshotStateList<DownloadItemState> = _downloads
var imageLoader: ImageLoader? = null
val downloads: SnapshotStateList<VideoDownloadItemState> = mutableStateListOf()
// map id -> state object reference (no index bookkeeping)
private val idToState: MutableMap<Int, DownloadItemState> = mutableMapOf()
private val idToState: MutableMap<Int, VideoDownloadItemState> = mutableMapOf()
fun modelToVideo(model: VideoDownloadItemState): Video?
{
val fv = videoLibrary.classesMap.map { it.value }.flatten()
return fv.firstOrNull { it.klass == model.klass && it.id == model.vid }
}
private val fetchListener = object : FetchListener {
override fun onAdded(download: Download) { handleUpsert(download) }
override fun onQueued(download: Download, waitingOnNetwork: Boolean) { handleUpsert(download) }
override fun onAdded(download: Download) {
handleUpsert(download)
}
override fun onQueued(download: Download, waitingOnNetwork: Boolean) {
handleUpsert(download)
}
override fun onWaitingNetwork(download: Download) {
}
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) {
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)
override fun onProgress(
download: Download,
etaInMilliSeconds: Long,
downloadedBytesPerSecond: Long
) {
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, totalBlocks: Int) { handleUpsert(download) }
override fun onPaused(download: Download) {
handleUpsert(download)
}
override fun onResumed(download: Download) {
handleUpsert(download)
}
override fun onCompleted(download: Download) {
handleUpsert(download)
if (download.extras.getString("type", "") == "main") {
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)
}
}
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,
totalBlocks: Int
) {
handleUpsert(download)
}
override fun onStarted(
download: Download,
downloadBlocks: List<DownloadBlock>,
@@ -60,7 +110,13 @@ class TransmissionScreenViewModel @Inject constructor(
handleUpsert(download)
}
override fun onError(download: Download, error: com.tonyodev.fetch2.Error, throwable: Throwable?) { handleUpsert(download) }
override fun onError(
download: Download,
error: com.tonyodev.fetch2.Error,
throwable: Throwable?
) {
handleUpsert(download)
}
}
private fun handleUpsert(download: Download) {
@@ -89,7 +145,7 @@ class TransmissionScreenViewModel @Inject constructor(
} else {
// new item: add to head (or tail depending on preference)
val newState = downloadToState(download)
_downloads.add(0, newState)
downloads.add(0, newState)
idToState[newState.id] = newState
}
}
@@ -97,19 +153,20 @@ class TransmissionScreenViewModel @Inject constructor(
private fun removeOnMain(id: Int) {
val state = idToState.remove(id)
if (state != null) {
_downloads.remove(state)
downloads.remove(state)
} else {
val idx = _downloads.indexOfFirst { it.id == id }
val idx = downloads.indexOfFirst { it.id == id }
if (idx >= 0) {
val removed = _downloads.removeAt(idx)
val removed = downloads.removeAt(idx)
idToState.remove(removed.id)
}
}
}
private fun downloadToState(download: Download): DownloadItemState {
private fun downloadToState(download: Download): VideoDownloadItemState {
val filePath = download.file
return DownloadItemState(
return VideoDownloadItemState(
id = download.id,
fileName = download.request.extras.getString("name", ""),
filePath = filePath,
@@ -119,7 +176,8 @@ class TransmissionScreenViewModel @Inject constructor(
downloadedBytes = download.downloaded,
totalBytes = download.total,
klass = download.extras.getString("class", ""),
vid = download.extras.getString("id", "")
vid = download.extras.getString("id", ""),
type = download.extras.getString("type", "")
)
}
@@ -140,19 +198,25 @@ class TransmissionScreenViewModel @Inject constructor(
}
init {
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
}
.build()
viewModelScope.launch {
fetchManager.setListener(fetchListener)
withContext(Dispatchers.Main) {
fetchManager.getAllDownloads { list ->
_downloads.clear()
downloads.clear()
idToState.clear()
list.sortedBy { it.extras.getString("name", "") }.forEach { d ->
val s = downloadToState(d)
_downloads.add(s)
downloads.add(s)
idToState[s.id] = s
}
}
}
}
}
}
}

View File

@@ -47,6 +47,7 @@ import androidx.core.net.toUri
import androidx.media3.common.Tracks
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import com.acitelight.aether.Global
import com.acitelight.aether.model.KeyImage
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@@ -99,7 +100,7 @@ class VideoPlayerViewModel @Inject constructor(
return
_init = true
val vs = videoId.hexToString().split(",").map { it.split("/") }
val vs = videoId.hexToString().split(",").map { it.split("/") }.toMutableList()
imageLoader = ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(createOkHttp()))
@@ -107,11 +108,11 @@ class VideoPlayerViewModel @Inject constructor(
.build()
viewModelScope.launch {
videos = mediaManager.queryVideoBulk(vs.first()[0], vs.map { it[1] })!!
val ii = database.userDao().getAll().first()
val ix = ii.filter { it.id in videos.map{ m -> m.id } }.maxByOrNull { it.time }
videos = mediaManager.queryVideoBulk(vs.first()[0], vs.map { it[1] })!!
startPlay(
if (ix != null)
videos.first { it.id == ix.id }