Files
Aether/app/src/main/java/com/acitelight/aether/viewModel/TransmissionScreenViewModel.kt
2025-09-29 20:34:56 +08:00

310 lines
10 KiB
Kotlin

package com.acitelight.aether.viewModel
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.Global.updateRelate
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoDownloadItemState
import com.acitelight.aether.service.ApiClient
import com.acitelight.aether.service.FetchManager
import com.acitelight.aether.service.MediaManager
import com.acitelight.aether.service.VideoLibrary
import com.acitelight.aether.view.toHex
import com.tonyodev.fetch2.Download
import com.tonyodev.fetch2.FetchListener
import com.tonyodev.fetch2.Status
import com.tonyodev.fetch2core.DownloadBlock
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject
@HiltViewModel
class TransmissionScreenViewModel @Inject constructor(
val fetchManager: FetchManager,
@ApplicationContext val context: Context,
val videoLibrary: VideoLibrary,
val mediaManager: MediaManager,
val apiClient: ApiClient
) : ViewModel() {
var imageLoader: ImageLoader? = null
val downloads: SnapshotStateList<VideoDownloadItemState> = mutableStateListOf()
// map id -> state object reference (no index bookkeeping)
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 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) {
handleUpsert(download)
if (download.extras.getString("type", "") == "main") {
val ii = videoLibrary.classesMap[download.extras.getString(
"class",
""
)]?.indexOfFirst { it.id == download.extras.getString("id", "") }
if (ii != null) {
val newi =
videoLibrary.classesMap[download.extras.getString("class", "")]?.get(ii)
if (newi != null) videoLibrary.classesMap[download.extras.getString(
"class",
""
)]?.set(
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>, totalBlocks: Int
) {
handleUpsert(download)
}
override fun onError(
download: Download, error: com.tonyodev.fetch2.Error, throwable: Throwable?
) {
handleUpsert(download)
}
}
private fun handleUpsert(download: Download) {
viewModelScope.launch(Dispatchers.Main) {
upsertOnMain(download)
}
val state = downloadToState(download)
if (!videoLibrary.classes.contains(state.klass)) videoLibrary.classes.add(state.klass)
if (!videoLibrary.classesMap.containsKey(state.klass)) videoLibrary.classesMap[state.klass] =
mutableStateListOf()
if (videoLibrary.classesMap[state.klass]?.any { it.id == state.vid } != true) {
viewModelScope.launch(Dispatchers.IO) {
val v = mediaManager.queryVideo(state.klass, state.vid, state)
if (v != null) {
videoLibrary.classesMap[state.klass]?.add(v)
}
}
}
}
private fun handleRemove(id: Int) {
viewModelScope.launch(Dispatchers.Main) {
removeOnMain(id)
}
}
private fun upsertOnMain(download: Download) {
val existing = idToState[download.id]
if (existing != null) {
// update fields in-place -> minimal recomposition
existing.filePath = download.file
existing.fileName = download.request.extras.getString("name", "")
existing.url = download.url
existing.progress = download.progress
existing.status = download.status
existing.downloadedBytes = download.downloaded
existing.totalBytes = download.total
} else {
// new item: add to head (or tail depending on preference)
val newState = downloadToState(download)
downloads.add(0, newState)
idToState[newState.id] = newState
}
}
private fun removeOnMain(id: Int) {
val state = idToState.remove(id)
if (state != null) {
downloads.remove(state)
} else {
val idx = downloads.indexOfFirst { it.id == id }
if (idx >= 0) {
val removed = downloads.removeAt(idx)
idToState.remove(removed.id)
}
}
}
private fun downloadToState(download: Download): VideoDownloadItemState {
val filePath = download.file
return VideoDownloadItemState(
id = download.id,
fileName = download.request.extras.getString("name", ""),
filePath = filePath,
url = download.url,
progress = download.progress,
status = download.status,
downloadedBytes = download.downloaded,
totalBytes = download.total,
klass = download.extras.getString("class", ""),
vid = download.extras.getString("id", ""),
type = download.extras.getString("type", "")
)
}
// UI actions delegated to FetchManager
fun pause(id: Int) = fetchManager.pause(id)
fun resume(id: Int) = fetchManager.resume(id)
fun retry(id: Int) = fetchManager.retry(id)
fun delete(id: Int) {
fetchManager.delete(id) {
viewModelScope.launch(Dispatchers.Main) { removeOnMain(id) }
}
}
override fun onCleared() {
super.onCleared()
fetchManager.removeListener()
}
suspend fun playStart(model: VideoDownloadItemState, navigator: NavHostController)
{
val downloaded = fetchManager.getAllDownloadsAsync().filter {
it.status == Status.COMPLETED && it.extras.getString(
"class",
""
) != "comic" && it.extras.getString(
"type",
""
) == "main"
}
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)
}
updateRelate(
jsonQuery,
jsonQuery.first { it.id == model.vid && it.klass == model.klass }
)
val playList = mutableListOf<String>()
val fv = 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.sortedWith(compareBy(naturalOrder()) { it.video.name })) {
playList.add("${i.klass}/${i.id}")
}
}
val route = "video_player_route/${(playList.joinToString(",") + "|${model.vid}").toHex()}"
withContext(Dispatchers.Main) {
navigator.navigate(route)
}
}
init {
imageLoader = ImageLoader.Builder(context).components {
add(OkHttpNetworkFetcherFactory(apiClient.getClient()))
}.build()
viewModelScope.launch {
fetchManager.setListener(fetchListener)
val downloaded = fetchManager.getAllDownloadsAsync()
downloads.clear()
idToState.clear()
downloaded.forEach { d ->
val s = downloadToState(d)
downloads.add(s)
idToState[s.id] = s
if (d.extras.getString("type", "") == "main") {
if (!videoLibrary.classes.contains(s.klass))
videoLibrary.classes.add(s.klass)
if (!videoLibrary.classesMap.containsKey(s.klass)) videoLibrary.classesMap[s.klass] =
mutableStateListOf()
if (videoLibrary.classesMap[s.klass]?.any { it.id == s.vid } != true) {
val v = mediaManager.queryVideo(s.klass, s.vid, s)
if (v != null) {
videoLibrary.classesMap[s.klass]?.add(v)
}
}
}
}
}
}
}