[feat] Better Transmission UI

This commit is contained in:
acite
2025-09-29 20:34:56 +08:00
parent 02d8d30da7
commit 8b5adfd6b7
8 changed files with 614 additions and 247 deletions

View File

@@ -231,6 +231,7 @@ class ApiClient @Inject constructor(
} }
if (selectedUrl == null) { if (selectedUrl == null) {
client = createOkHttp()
throw Exception("No reachable URL found") throw Exception("No reachable URL found")
} }
@@ -242,11 +243,6 @@ class ApiClient @Inject constructor(
(context as AetherApp).abyssService?.proxy?.config(getBase().toUri().host!!, 4096) (context as AetherApp).abyssService?.proxy?.config(getBase().toUri().host!!, 4096)
} }
api = createRetrofit().create(ApiInterface::class.java) api = createRetrofit().create(ApiInterface::class.java)
Log.i("Delay Analyze", "Start Abyss Hello")
val h = api!!.hello()
Log.i("Delay Analyze", "Abyss Hello: ${h.string()}")
return base return base
} catch (_: Exception) { } catch (_: Exception) {
api = null api = null

View File

@@ -77,6 +77,10 @@ class FetchManager @Inject constructor(
fetch?.resume(id) fetch?.resume(id)
} }
fun retry(id: Int) {
fetch?.retry(id)
}
fun cancel(id: Int) { fun cancel(id: Int) {
fetch?.cancel(id) fetch?.cancel(id)
} }

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderColors
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -63,9 +64,13 @@ fun BiliMiniSlider(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
value: Float, value: Float,
onValueChange: (Float) -> Unit, onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
colors: SliderColors = SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF),
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
)
) { ) {
val colorScheme = MaterialTheme.colorScheme
val trackHeight = 3.dp val trackHeight = 3.dp
Slider( Slider(
@@ -73,11 +78,8 @@ fun BiliMiniSlider(
onValueChange = onValueChange, onValueChange = onValueChange,
valueRange = valueRange, valueRange = valueRange,
modifier = modifier, modifier = modifier,
colors = SliderDefaults.colors( colors = colors,
thumbColor = Color(0xFFFFFFFF), enabled = false,
activeTrackColor = colorScheme.primary,
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
),
thumb = { thumb = {
}, },
@@ -86,14 +88,14 @@ fun BiliMiniSlider(
Modifier Modifier
.height(trackHeight) .height(trackHeight)
.fillMaxWidth() .fillMaxWidth()
.background(Color.LightGray.copy(alpha = 0.3f), RoundedCornerShape(50)) .background(colors.inactiveTrackColor, RoundedCornerShape(50))
) { ) {
Box( Box(
Modifier Modifier
.align(Alignment.CenterStart) .align(Alignment.CenterStart)
.fillMaxWidth(value) .fillMaxWidth(value)
.fillMaxHeight() .fillMaxHeight()
.background(colorScheme.primary, RoundedCornerShape(50)) .background(colors.activeTrackColor, RoundedCornerShape(50))
) )
} }
} }

View File

@@ -84,15 +84,27 @@ fun TransmissionScreen(
maxLines = 1 maxLines = 1
) )
val downloading = downloads.filter { it.status == Status.DOWNLOADING }
BiliMiniSlider(
value = if (downloading.sumOf { it.totalBytes } == 0L) 1f else downloading.sumOf { it.downloadedBytes } / downloading.sumOf { it.totalBytes }.toFloat(),
modifier = Modifier
.height(6.dp)
.align(Alignment.End)
.fillMaxWidth(),
onValueChange = {
}
)
HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color) HorizontalDivider(Modifier.padding(8.dp), 2.dp, DividerDefaults.color)
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) )
{ {
items(downloads.filter { it.type == "main" }, key = { it.id }) { item -> items(downloads.filter { it.type == "main" }.sortedBy { it.status == Status.COMPLETED }, key = { it.id }) { item ->
VideoDownloadCard( VideoDownloadCardMini(
navigator = navigator, navigator = navigator,
viewModel = transmissionScreenViewModel, viewModel = transmissionScreenViewModel,
model = item, model = item,
@@ -112,13 +124,19 @@ fun TransmissionScreen(
for (i in downloadToGroup( for (i in downloadToGroup(
item, item,
downloads downloads
)) transmissionScreenViewModel.cancel(i.id) )) transmissionScreenViewModel.delete(i.id)
}, },
onDelete = { onDelete = {
for (i in downloadToGroup( for (i in downloadToGroup(
item, item,
downloads downloads
)) transmissionScreenViewModel.delete(i.id) )) transmissionScreenViewModel.delete(i.id)
},
onRetry = {
for (i in downloadToGroup(
item,
downloads
)) transmissionScreenViewModel.retry(i.id)
} }
) )
} }
@@ -126,231 +144,6 @@ fun TransmissionScreen(
} }
} }
@Composable
private fun VideoDownloadCard(
navigator: NavHostController,
viewModel: TransmissionScreenViewModel,
model: VideoDownloadItemState,
onPause: () -> Unit,
onResume: () -> Unit,
onCancel: () -> Unit,
onDelete: () -> Unit
) {
Card(
shape = RoundedCornerShape(8.dp),
elevation = CardDefaults.cardElevation(4.dp),
modifier = Modifier
.fillMaxWidth()
.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(
"class",
""
) != "comic" && it.extras.getString(
"type",
""
) == "main"
}
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 playList = mutableListOf<String>()
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.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)
}
}
}
})
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
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)
)
{
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.align(Alignment.CenterStart)
) {
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"
)
)
.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(viewModel.apiClient))
.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)) {
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
LinearProgressIndicator(
progress = { abs(model.progress).coerceIn(0, 100) / 100f },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 8.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
// action buttons
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
when (model.status) {
Status.DOWNLOADING -> {
Button(onClick = onPause) {
Icon(imageVector = Icons.Default.Pause, contentDescription = "Pause")
Text(text = " Pause", modifier = Modifier.padding(start = 6.dp))
}
Button(onClick = onCancel) {
Icon(imageVector = Icons.Default.Stop, contentDescription = "Cancel")
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
}
}
Status.PAUSED, Status.QUEUED -> {
Button(onClick = onResume) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Resume"
)
Text(text = " Resume", modifier = Modifier.padding(start = 6.dp))
}
Button(onClick = onCancel) {
Icon(imageVector = Icons.Default.Stop, contentDescription = "Cancel")
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
}
}
Status.COMPLETED -> {
Button(onClick = onDelete) {
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
Text(text = " Delete", modifier = Modifier.padding(start = 6.dp))
}
}
else -> {
// for FAILED, CANCELLED, REMOVED etc.
Button(onClick = onResume) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Retry"
)
Text(text = " Retry", modifier = Modifier.padding(start = 6.dp))
}
Button(onClick = onDelete) {
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
Text(text = " Delete", modifier = Modifier.padding(start = 6.dp))
}
}
}
}
}
}
}
fun downloadToGroup( fun downloadToGroup(
i: VideoDownloadItemState, i: VideoDownloadItemState,
downloads: List<VideoDownloadItemState> downloads: List<VideoDownloadItemState>

View File

@@ -0,0 +1,274 @@
package com.acitelight.aether.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.sp
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.Global.updateRelate
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoDownloadItemState
import com.acitelight.aether.viewModel.TransmissionScreenViewModel
import com.tonyodev.fetch2.Status
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import kotlin.math.abs
@Composable
fun VideoDownloadCard(
navigator: NavHostController,
viewModel: TransmissionScreenViewModel,
model: VideoDownloadItemState,
onPause: () -> Unit,
onResume: () -> Unit,
onCancel: () -> Unit,
onDelete: () -> Unit,
onRetry: () -> Unit
) {
Card(
shape = RoundedCornerShape(8.dp),
elevation = CardDefaults.cardElevation(4.dp),
modifier = Modifier
.fillMaxWidth()
.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(
"class",
""
) != "comic" && it.extras.getString(
"type",
""
) == "main"
}
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 playList = mutableListOf<String>()
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.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)
}
}
}
})
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = model.fileName, style = MaterialTheme.typography.titleMedium, maxLines = 2)
// Text(text = model.filePath, style = MaterialTheme.typography.titleSmall)
}
}
Box(
Modifier
.fillMaxWidth()
.padding(top = 5.dp)
)
{
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.align(Alignment.CenterStart)
) {
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"
)
)
.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(viewModel.apiClient))
.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)) {
Text(
text = "${model.progress.coerceIn(0, 100)}%",
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
LinearProgressIndicator(
progress = { abs(model.progress).coerceIn(0, 100) / 100f },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 8.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
// action buttons
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
when (model.status) {
Status.DOWNLOADING -> {
Button(onClick = onPause) {
Icon(imageVector = Icons.Default.Pause, contentDescription = "Pause")
Text(text = " Pause", modifier = Modifier.padding(start = 6.dp))
}
Button(onClick = onCancel) {
Icon(imageVector = Icons.Default.Stop, contentDescription = "Cancel")
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
}
}
Status.PAUSED, Status.QUEUED -> {
Button(onClick = onResume) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Resume"
)
Text(text = " Resume", modifier = Modifier.padding(start = 6.dp))
}
Button(onClick = onCancel) {
Icon(imageVector = Icons.Default.Stop, contentDescription = "Cancel")
Text(text = " Cancel", modifier = Modifier.padding(start = 6.dp))
}
}
Status.COMPLETED -> {
Button(onClick = onDelete) {
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
Text(text = " Delete", modifier = Modifier.padding(start = 6.dp))
}
}
else -> {
// for FAILED, CANCELLED, REMOVED etc.
Button(onClick = onRetry) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "Retry"
)
Text(text = " Retry", modifier = Modifier.padding(start = 6.dp))
}
Button(onClick = onDelete) {
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")
Text(text = " Delete", modifier = Modifier.padding(start = 6.dp))
}
}
}
}
}
}
}

View File

@@ -0,0 +1,240 @@
package com.acitelight.aether.view
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.ModifierLocalBeyondBoundsLayout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.acitelight.aether.Global.updateRelate
import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoDownloadItemState
import com.acitelight.aether.viewModel.TransmissionScreenViewModel
import com.tonyodev.fetch2.Status
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import kotlin.math.abs
@Composable
fun VideoDownloadCardMini(
navigator: NavHostController,
viewModel: TransmissionScreenViewModel,
model: VideoDownloadItemState,
onPause: () -> Unit,
onResume: () -> Unit,
onCancel: () -> Unit,
onDelete: () -> Unit,
onRetry: () -> Unit
) {
val video = viewModel.modelToVideo(model)
val imageModel =
if (video == null)
ImageRequest.Builder(LocalContext.current)
.data(
File(
viewModel.context.getExternalFilesDir(null),
"videos/${model.klass}/${model.vid}/cover.jpg"
)
)
.memoryCacheKey("${model.klass}/${model.vid}/cover")
.diskCacheKey("${model.klass}/${model.vid}/cover")
.build()
else
ImageRequest.Builder(LocalContext.current)
.data(video.getCover(viewModel.apiClient))
.memoryCacheKey("${model.klass}/${model.vid}/cover")
.diskCacheKey("${model.klass}/${model.vid}/cover")
.build()
Card(
shape = RoundedCornerShape(8.dp),
elevation = CardDefaults.cardElevation(4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.background(Color.Transparent)
.clickable(onClick = {
when (model.status) {
Status.COMPLETED -> viewModel.viewModelScope.launch(Dispatchers.IO)
{
viewModel.playStart(model, navigator)
}
Status.DOWNLOADING -> onPause()
Status.PAUSED -> onResume()
Status.ADDED, Status.FAILED, Status.CANCELLED -> onRetry()
else -> {}
}
})
.height(85.dp)
) {
Row(
modifier = Modifier.fillMaxSize()
)
{
Box(Modifier
.fillMaxHeight()
.widthIn(max = 152.dp))
{
AsyncImage(
model = imageModel,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
IconButton(
onClick = {
},
Modifier
.padding(2.dp)
.size(24.dp)
.align(Alignment.TopStart)
.background(MaterialTheme.colorScheme.error, RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(4.dp))
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Delete",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
}
Box(
Modifier
.fillMaxSize()
.padding(all = 4.dp)
.padding(end = 4.dp)
)
{
Text(
text = model.fileName,
lineHeight = 14.sp,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
modifier = Modifier.align(Alignment.TopEnd)
)
Column(Modifier.align(Alignment.BottomEnd)) {
Text(
modifier = Modifier.align(Alignment.End),
text = when (model.status) {
Status.COMPLETED -> "Completed"
Status.PAUSED, Status.QUEUED -> "Paused"
Status.DOWNLOADING -> "Downloading"
else -> "Error"
},
fontSize = 10.sp,
lineHeight = 11.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
Row(Modifier
.align(Alignment.End)
.padding(vertical = 2.dp)) {
Text(
modifier = Modifier,
text = "%.2f MB/%.2f MB".format(
model.downloadedBytes / (1024.0 * 1024.0),
model.totalBytes / (1024.0 * 1024.0)
),
fontSize = 10.sp,
lineHeight = 11.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
Spacer(Modifier.width(12.dp))
Text(
text = "${model.progress.coerceIn(0, 100)}%",
modifier = Modifier,
fontSize = 10.sp,
lineHeight = 11.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
BiliMiniSlider(
value = abs(model.progress).coerceIn(0, 100) / 100f,
modifier = Modifier
.height(6.dp)
.align(Alignment.End)
.fillMaxWidth(),
onValueChange = {
},
colors = when(model.status)
{
Status.DOWNLOADING, Status.QUEUED, Status.ADDED -> SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF),
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
)
Status.PAUSED -> SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF),
activeTrackColor = Color(0xFFFFA500),
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
)
Status.COMPLETED -> SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF),
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
)
else -> SliderDefaults.colors(
thumbColor = Color(0xFFFFFFFF),
activeTrackColor = MaterialTheme.colorScheme.error,
inactiveTrackColor = Color.LightGray.copy(alpha = 0.4f)
)
}
)
}
}
}
}
}

View File

@@ -5,21 +5,28 @@ 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 androidx.navigation.NavHostController
import coil3.ImageLoader import coil3.ImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.acitelight.aether.Global.updateRelate
import com.acitelight.aether.model.Video import com.acitelight.aether.model.Video
import com.acitelight.aether.model.VideoDownloadItemState import com.acitelight.aether.model.VideoDownloadItemState
import com.acitelight.aether.service.ApiClient import com.acitelight.aether.service.ApiClient
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.VideoLibrary import com.acitelight.aether.service.VideoLibrary
import com.acitelight.aether.view.toHex
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.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 dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.File
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -128,7 +135,7 @@ class TransmissionScreenViewModel @Inject constructor(
val state = downloadToState(download) val state = downloadToState(download)
if (videoLibrary.classes.contains(state.klass)) videoLibrary.classes.add(state.klass) if (!videoLibrary.classes.contains(state.klass)) videoLibrary.classes.add(state.klass)
if (!videoLibrary.classesMap.containsKey(state.klass)) videoLibrary.classesMap[state.klass] = if (!videoLibrary.classesMap.containsKey(state.klass)) videoLibrary.classesMap[state.klass] =
mutableStateListOf() mutableStateListOf()
@@ -204,7 +211,7 @@ class TransmissionScreenViewModel @Inject constructor(
// UI actions delegated to FetchManager // UI actions delegated to FetchManager
fun pause(id: Int) = fetchManager.pause(id) fun pause(id: Int) = fetchManager.pause(id)
fun resume(id: Int) = fetchManager.resume(id) fun resume(id: Int) = fetchManager.resume(id)
fun cancel(id: Int) = fetchManager.cancel(id) fun retry(id: Int) = fetchManager.retry(id)
fun delete(id: Int) { fun delete(id: Int) {
fetchManager.delete(id) { fetchManager.delete(id) {
viewModelScope.launch(Dispatchers.Main) { removeOnMain(id) } viewModelScope.launch(Dispatchers.Main) { removeOnMain(id) }
@@ -216,6 +223,56 @@ class TransmissionScreenViewModel @Inject constructor(
fetchManager.removeListener() 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 { init {
imageLoader = ImageLoader.Builder(context).components { imageLoader = ImageLoader.Builder(context).components {
add(OkHttpNetworkFetcherFactory(apiClient.getClient())) add(OkHttpNetworkFetcherFactory(apiClient.getClient()))

View File

@@ -46,8 +46,9 @@ class VideoScreenViewModel @Inject constructor(
fetchManager.configured.filter { it }.first() fetchManager.configured.filter { it }.first()
if (Global.loggedIn) { if (Global.loggedIn) {
videoLibrary.classes.addAll(mediaManager.listVideoKlasses()) videoLibrary.classes.addAll(
videoLibrary.classes.distinct() mediaManager.listVideoKlasses().filter { it !in videoLibrary.classes }
)
if(videoLibrary.classes.isEmpty()) if(videoLibrary.classes.isEmpty())
return return