feat(app/ui): tasks root media preview

Signed-off-by: rhunk <101876869+rhunk@users.noreply.github.com>
This commit is contained in:
rhunk 2024-06-05 15:09:36 +02:00
parent ae15ad7ce9
commit e45e96908b
2 changed files with 91 additions and 50 deletions

View File

@ -1,26 +1,35 @@
package me.rhunk.snapenhance.ui.manager.pages package me.rhunk.snapenhance.ui.manager.pages
import android.content.Intent import android.content.Intent
import android.graphics.drawable.ColorDrawable
import androidx.compose.foundation.Image
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.navigation.NavBackStackEntry import androidx.navigation.NavBackStackEntry
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -32,13 +41,10 @@ import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState
import me.rhunk.snapenhance.common.util.ktx.longHashCode import me.rhunk.snapenhance.common.util.ktx.longHashCode
import me.rhunk.snapenhance.download.DownloadProcessor import me.rhunk.snapenhance.download.DownloadProcessor
import me.rhunk.snapenhance.download.FFMpegProcessor import me.rhunk.snapenhance.download.FFMpegProcessor
import me.rhunk.snapenhance.task.PendingTask import me.rhunk.snapenhance.task.*
import me.rhunk.snapenhance.task.PendingTaskListener
import me.rhunk.snapenhance.task.Task
import me.rhunk.snapenhance.task.TaskStatus
import me.rhunk.snapenhance.task.TaskType
import me.rhunk.snapenhance.ui.manager.Routes import me.rhunk.snapenhance.ui.manager.Routes
import me.rhunk.snapenhance.ui.util.OnLifecycleEvent import me.rhunk.snapenhance.ui.util.OnLifecycleEvent
import me.rhunk.snapenhance.ui.util.coil.cacheKey
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
@ -138,29 +144,6 @@ class TasksRootSection : Routes.Route() {
var showConfirmDialog by remember { mutableStateOf(false) } var showConfirmDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
if (taskSelection.size == 1) {
val selectionExists by rememberAsyncMutableState(defaultValue = false) {
taskSelection.firstOrNull()?.second?.exists() == true
}
if (selectionExists) {
taskSelection.firstOrNull()?.second?.let { documentFile ->
IconButton(onClick = {
runCatching {
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(documentFile.uri, documentFile.type)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
})
taskSelection.clear()
}.onFailure {
context.log.error("Failed to open file ${taskSelection.first().second}", it)
}
}) {
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = "Open")
}
}
}
}
if (taskSelection.size > 1) { if (taskSelection.size > 1) {
val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) { val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) {
taskSelection.all { it.second?.type?.contains("video") == true } taskSelection.all { it.second?.type?.contains("video") == true }
@ -305,29 +288,85 @@ class TasksRootSection : Routes.Route() {
} }
} }
OutlinedCard(modifier = modifier fun toggleSelection() {
.clickable {
if (isSelected) { if (isSelected) {
taskSelection.removeIf { it.first == task } taskSelection.removeIf { it.first == task }
return@clickable return
} }
taskSelection.add(task to documentFile) taskSelection.add(task to documentFile)
} }
fun openFile() {
if (!isDocumentFileReadable || documentFile == null) return
runCatching {
context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply {
setDataAndType(documentFile!!.uri, documentFile!!.type)
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
})
}.onFailure {
context.log.error("Failed to open file ${documentFile?.uri}", it)
context.shortToast(translation["failed_to_open_file"])
}
}
OutlinedCard(modifier = modifier
.pointerInput(Unit) {
detectTapGestures(
onTap = {
if (taskSelection.size > 0) {
toggleSelection()
return@detectTapGestures
}
openFile()
},
onLongPress = {
if (taskSelection.size > 0) {
openFile()
return@detectTapGestures
}
toggleSelection()
}
)
}
.let { .let {
if (isSelected) { if (isSelected) {
it it
.border(2.dp, MaterialTheme.colorScheme.primary) .border(2.dp, MaterialTheme.colorScheme.primary)
.clip(MaterialTheme.shapes.medium) .clip(MaterialTheme.shapes.medium)
} else it } else it
}) { }
) {
Row( Row(
modifier = Modifier.padding(15.dp), modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column( Box(
modifier = Modifier.padding(end = 15.dp) modifier = Modifier
.padding(end = 15.dp)
.size(50.dp)
.clipToBounds(),
contentAlignment = Alignment.Center
) { ) {
var loadFailed by remember { mutableStateOf(false) }
documentFile?.let { documentFile?.let {
if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) {
Image(
painter = rememberAsyncImagePainter(
model = ImageRequest.Builder(context.androidContext)
.data(it.uri)
.cacheKey(it.uri.toString())
.placeholder(ColorDrawable(MaterialTheme.colorScheme.surfaceVariant.toArgb()))
.build(),
imageLoader = context.imageLoader,
onError = { loadFailed = true }
),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.size(50.dp)
.clip(MaterialTheme.shapes.medium)
)
} else {
when { when {
!isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found") !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found")
documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image") documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image")
@ -335,6 +374,7 @@ class TasksRootSection : Routes.Route() {
documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio") documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio")
else -> Icon(Icons.Filled.FileCopy, contentDescription = "File") else -> Icon(Icons.Filled.FileCopy, contentDescription = "File")
} }
}
} ?: run { } ?: run {
when (task.type) { when (task.type) {
TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download") TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download")
@ -409,12 +449,12 @@ class TasksRootSection : Routes.Route() {
override val content: @Composable (NavBackStackEntry) -> Unit = { override val content: @Composable (NavBackStackEntry) -> Unit = {
val scrollState = rememberLazyListState() val scrollState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
recentTasks = remember { mutableStateListOf() } recentTasks = rememberSaveable { mutableStateListOf() }
var lastFetchedTaskId: Long? by remember { mutableStateOf(null) } var lastFetchedTaskId: Long? by rememberSaveable { mutableStateOf(null) }
fun fetchNewRecentTasks() { fun fetchNewRecentTasks() {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE) val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20)
if (tasks.isNotEmpty()) { if (tasks.isNotEmpty()) {
lastFetchedTaskId = tasks.keys.last() lastFetchedTaskId = tasks.keys.last()
val activeTaskIds = activeTasks.map { it.taskId } val activeTaskIds = activeTasks.map { it.taskId }
@ -464,7 +504,7 @@ class TasksRootSection : Routes.Route() {
TaskCard(modifier = Modifier.padding(8.dp), task) TaskCard(modifier = Modifier.padding(8.dp), task)
} }
item { item {
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(40.dp))
LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
fetchNewRecentTasks() fetchNewRecentTasks()
} }

View File

@ -73,6 +73,7 @@
}, },
"tasks": { "tasks": {
"no_tasks": "No tasks", "no_tasks": "No tasks",
"failed_to_open_file": "Failed to open file",
"merge_files_toast": "Merging {count} files", "merge_files_toast": "Merging {count} files",
"remove_selected_tasks_title": "Are you sure you want to remove selected tasks?", "remove_selected_tasks_title": "Are you sure you want to remove selected tasks?",
"remove_all_tasks_title": "Are you sure you want to remove all tasks?", "remove_all_tasks_title": "Are you sure you want to remove all tasks?",