TasksRootSection.kt (23540B) - raw
1 package me.rhunk.snapenhance.ui.manager.pages 2 3 import android.content.Intent 4 import android.graphics.drawable.ColorDrawable 5 import androidx.compose.foundation.Image 6 import androidx.compose.foundation.border 7 import androidx.compose.foundation.clickable 8 import androidx.compose.foundation.gestures.detectTapGestures 9 import androidx.compose.foundation.layout.* 10 import androidx.compose.foundation.lazy.LazyColumn 11 import androidx.compose.foundation.lazy.items 12 import androidx.compose.foundation.lazy.rememberLazyListState 13 import androidx.compose.material.icons.Icons 14 import androidx.compose.material.icons.filled.* 15 import androidx.compose.material3.* 16 import androidx.compose.runtime.* 17 import androidx.compose.ui.Alignment 18 import androidx.compose.ui.Modifier 19 import androidx.compose.ui.draw.clip 20 import androidx.compose.ui.draw.clipToBounds 21 import androidx.compose.ui.graphics.StrokeCap 22 import androidx.compose.ui.graphics.toArgb 23 import androidx.compose.ui.input.pointer.pointerInput 24 import androidx.compose.ui.layout.ContentScale 25 import androidx.compose.ui.unit.dp 26 import androidx.core.net.toUri 27 import androidx.documentfile.provider.DocumentFile 28 import androidx.lifecycle.Lifecycle 29 import androidx.navigation.NavBackStackEntry 30 import coil.compose.rememberAsyncImagePainter 31 import coil.request.ImageRequest 32 import kotlinx.coroutines.CoroutineScope 33 import kotlinx.coroutines.Dispatchers 34 import kotlinx.coroutines.launch 35 import me.rhunk.snapenhance.bridge.DownloadCallback 36 import me.rhunk.snapenhance.common.data.download.DownloadMetadata 37 import me.rhunk.snapenhance.common.data.download.MediaDownloadSource 38 import me.rhunk.snapenhance.common.data.download.createNewFilePath 39 import me.rhunk.snapenhance.common.ui.TopBarActionButton 40 import me.rhunk.snapenhance.common.ui.rememberAsyncMutableState 41 import me.rhunk.snapenhance.common.util.ktx.longHashCode 42 import me.rhunk.snapenhance.download.DownloadProcessor 43 import me.rhunk.snapenhance.download.FFMpegProcessor 44 import me.rhunk.snapenhance.task.* 45 import me.rhunk.snapenhance.ui.manager.Routes 46 import me.rhunk.snapenhance.ui.util.OnLifecycleEvent 47 import me.rhunk.snapenhance.ui.util.coil.cacheKey 48 import java.io.File 49 import java.util.UUID 50 import kotlin.math.absoluteValue 51 52 class TasksRootSection : Routes.Route() { 53 private var activeTasks by mutableStateOf(listOf<PendingTask>()) 54 private lateinit var recentTasks: MutableList<Task> 55 private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>() 56 57 private fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) { 58 scope.launch(Dispatchers.IO) { 59 activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList() 60 } 61 } 62 63 private fun mergeSelection(selection: List<Pair<Task, DocumentFile>>) { 64 val firstTask = selection.first().first 65 66 val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16) 67 val pendingTask = context.taskManager.createPendingTask( 68 Task(TaskType.DOWNLOAD, "Merge ${selection.size} files", firstTask.author, taskHash) 69 ) 70 pendingTask.status = TaskStatus.RUNNING 71 fetchActiveTasks() 72 73 context.coroutineScope.launch { 74 val filesToMerge = mutableListOf<File>() 75 76 selection.forEach { (task, documentFile) -> 77 val tempFile = File.createTempFile(task.hash, "." + documentFile.name?.substringAfterLast("."), context.androidContext.cacheDir).also { 78 it.deleteOnExit() 79 } 80 81 runCatching { 82 pendingTask.updateProgress("Copying ${documentFile.name}") 83 context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream -> 84 //copy with progress 85 val length = documentFile.length().toFloat() 86 tempFile.outputStream().use { outputStream -> 87 val buffer = ByteArray(16 * 1024) 88 var read: Int 89 while (inputStream.read(buffer).also { read = it } != -1) { 90 outputStream.write(buffer, 0, read) 91 pendingTask.updateProgress("Copying ${documentFile.name}", (outputStream.channel.position().toFloat() / length * 100f).toInt()) 92 } 93 outputStream.flush() 94 filesToMerge.add(tempFile) 95 } 96 } 97 }.onFailure { 98 pendingTask.fail("Failed to copy file $documentFile to $tempFile") 99 filesToMerge.forEach { it.delete() } 100 return@launch 101 } 102 } 103 104 val mergedFile = File.createTempFile("merged", ".mp4", context.androidContext.cacheDir).also { 105 it.deleteOnExit() 106 } 107 108 runCatching { 109 context.shortToast(translation.format("merge_files_toast", "count" to filesToMerge.size.toString())) 110 FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute( 111 FFMpegProcessor.Request(FFMpegProcessor.Action.MERGE_MEDIA, filesToMerge.map { it.absolutePath }, mergedFile) 112 ) 113 DownloadProcessor(context, object: DownloadCallback.Default() { 114 override fun onSuccess(outputPath: String) { 115 context.log.verbose("Merged files to $outputPath") 116 } 117 }).saveMediaToGallery(pendingTask, mergedFile, DownloadMetadata( 118 mediaIdentifier = taskHash, 119 outputPath = createNewFilePath( 120 context.config.root, 121 taskHash, 122 downloadSource = MediaDownloadSource.MERGED, 123 mediaAuthor = firstTask.author, 124 creationTimestamp = System.currentTimeMillis() 125 ), 126 mediaAuthor = firstTask.author, 127 downloadSource = MediaDownloadSource.MERGED.translate(context.translation), 128 iconUrl = null 129 )) 130 }.onFailure { 131 context.log.error("Failed to merge files", it) 132 pendingTask.fail(it.message ?: "Failed to merge files") 133 }.onSuccess { 134 pendingTask.success() 135 } 136 filesToMerge.forEach { it.delete() } 137 mergedFile.delete() 138 }.also { 139 pendingTask.addListener(PendingTaskListener(onCancel = { it.cancel() })) 140 } 141 } 142 143 override val topBarActions: @Composable (RowScope.() -> Unit) = { 144 var showConfirmDialog by remember { mutableStateOf(false) } 145 val coroutineScope = rememberCoroutineScope() 146 147 if (taskSelection.size > 1) { 148 val canMergeSelection by rememberAsyncMutableState(defaultValue = false, keys = arrayOf(taskSelection.size)) { 149 taskSelection.all { it.second?.type?.contains("video") == true } 150 } 151 152 if (canMergeSelection) { 153 TopBarActionButton( 154 onClick = { 155 mergeSelection(taskSelection.toList().also { 156 taskSelection.clear() 157 }.map { it.first to it.second!! }) 158 }, 159 icon = Icons.Filled.Merge, 160 text = translation["merge_button"] 161 ) 162 } 163 } 164 165 IconButton(onClick = { 166 showConfirmDialog = true 167 }) { 168 Icon(Icons.Filled.Delete, contentDescription = "Clear tasks") 169 } 170 171 if (showConfirmDialog) { 172 var alsoDeleteFiles by remember { mutableStateOf(false) } 173 174 AlertDialog( 175 onDismissRequest = { showConfirmDialog = false }, 176 title = { 177 if (taskSelection.isNotEmpty()) { 178 Text(translation.format("remove_selected_tasks_confirm", "count" to taskSelection.size.toString())) 179 } else { 180 Text(translation["remove_all_tasks_confirm"]) 181 } 182 }, 183 text = { 184 Column { 185 if (taskSelection.isNotEmpty()) { 186 Text(translation["remove_selected_tasks_title"]) 187 Row ( 188 modifier = Modifier 189 .padding(top = 10.dp) 190 .fillMaxWidth() 191 .clickable { 192 alsoDeleteFiles = !alsoDeleteFiles 193 }, 194 horizontalArrangement = Arrangement.spacedBy(5.dp), 195 verticalAlignment = Alignment.CenterVertically 196 ) { 197 Checkbox(checked = alsoDeleteFiles, onCheckedChange = { 198 alsoDeleteFiles = it 199 }) 200 Text(translation["delete_files_option"]) 201 } 202 } else { 203 Text(translation["remove_all_tasks_title"]) 204 } 205 } 206 }, 207 confirmButton = { 208 Button( 209 onClick = { 210 showConfirmDialog = false 211 if (taskSelection.isNotEmpty()) { 212 taskSelection.forEach { (task, documentFile) -> 213 coroutineScope.launch(Dispatchers.IO) { 214 context.taskManager.removeTask(task) 215 if (alsoDeleteFiles) { 216 documentFile?.delete() 217 } 218 } 219 recentTasks.remove(task) 220 } 221 activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) } 222 taskSelection.clear() 223 } else { 224 coroutineScope.launch(Dispatchers.IO) { 225 context.taskManager.clearAllTasks() 226 } 227 recentTasks.clear() 228 activeTasks.forEach { 229 runCatching { 230 it.cancel() 231 }.onFailure { throwable -> 232 context.log.error("Failed to cancel task $it", throwable) 233 } 234 } 235 activeTasks = listOf() 236 context.taskManager.getActiveTasks().clear() 237 } 238 } 239 ) { 240 Text(context.translation["button.positive"]) 241 } 242 }, 243 dismissButton = { 244 Button( 245 onClick = { 246 showConfirmDialog = false 247 } 248 ) { 249 Text(context.translation["button.negative"]) 250 } 251 } 252 ) 253 } 254 } 255 256 @Composable 257 private fun TaskCard(modifier: Modifier, task: Task, pendingTask: PendingTask? = null) { 258 var taskStatus by remember { mutableStateOf(task.status) } 259 var taskProgressLabel by remember { mutableStateOf<String?>(null) } 260 var taskProgress by remember { mutableIntStateOf(-1) } 261 val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } } 262 263 var documentFileMimeType by remember { mutableStateOf("") } 264 var isDocumentFileReadable by remember { mutableStateOf(true) } 265 val documentFile by rememberAsyncMutableState(defaultValue = null, keys = arrayOf(taskStatus.key)) { 266 DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@rememberAsyncMutableState null)?.apply { 267 documentFileMimeType = type ?: "" 268 isDocumentFileReadable = canRead() 269 } 270 } 271 272 273 val listener = remember { PendingTaskListener( 274 onStateChange = { 275 taskStatus = it 276 }, 277 onProgress = { label, progress -> 278 taskProgressLabel = label 279 taskProgress = progress 280 } 281 ) } 282 283 LaunchedEffect(Unit) { 284 pendingTask?.addListener(listener) 285 } 286 287 DisposableEffect(Unit) { 288 onDispose { 289 pendingTask?.removeListener(listener) 290 } 291 } 292 293 fun toggleSelection() { 294 if (isSelected) { 295 taskSelection.removeIf { it.first == task } 296 return 297 } 298 taskSelection.add(task to documentFile) 299 } 300 301 fun openFile() { 302 if (!isDocumentFileReadable || documentFile == null) return 303 runCatching { 304 context.androidContext.startActivity(Intent(Intent.ACTION_VIEW).apply { 305 setDataAndType(documentFile!!.uri, documentFile!!.type) 306 flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK 307 }) 308 }.onFailure { 309 context.log.error("Failed to open file ${documentFile?.uri}", it) 310 context.shortToast(translation["failed_to_open_file"]) 311 } 312 } 313 314 OutlinedCard(modifier = modifier 315 .pointerInput(Unit) { 316 detectTapGestures( 317 onTap = { 318 if (taskSelection.isNotEmpty()) { 319 toggleSelection() 320 return@detectTapGestures 321 } 322 openFile() 323 }, 324 onLongPress = { 325 if (taskSelection.isNotEmpty()) { 326 openFile() 327 return@detectTapGestures 328 } 329 toggleSelection() 330 } 331 ) 332 } 333 .let { 334 if (isSelected) { 335 it 336 .border(2.dp, MaterialTheme.colorScheme.primary) 337 .clip(MaterialTheme.shapes.medium) 338 } else it 339 } 340 ) { 341 Row( 342 modifier = Modifier.padding(12.dp), 343 verticalAlignment = Alignment.CenterVertically 344 ) { 345 Box( 346 modifier = Modifier 347 .padding(end = 15.dp) 348 .size(50.dp) 349 .clipToBounds(), 350 contentAlignment = Alignment.Center 351 ) { 352 var loadFailed by remember { mutableStateOf(false) } 353 documentFile?.let { 354 if (taskStatus.isFinalStage() && isDocumentFileReadable && !loadFailed && (documentFileMimeType.contains("image") || documentFileMimeType.contains("video"))) { 355 Image( 356 painter = rememberAsyncImagePainter( 357 model = ImageRequest.Builder(context.androidContext) 358 .data(it.uri) 359 .cacheKey(it.uri.toString()) 360 .placeholder(ColorDrawable(MaterialTheme.colorScheme.surfaceVariant.toArgb())) 361 .build(), 362 imageLoader = context.imageLoader, 363 onError = { loadFailed = true } 364 ), 365 contentDescription = null, 366 contentScale = ContentScale.FillWidth, 367 modifier = Modifier 368 .size(50.dp) 369 .clip(MaterialTheme.shapes.medium) 370 ) 371 } else { 372 when { 373 !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found") 374 documentFileMimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image") 375 documentFileMimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video") 376 documentFileMimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio") 377 else -> Icon(Icons.Filled.FileCopy, contentDescription = "File") 378 } 379 } 380 } ?: run { 381 when (task.type) { 382 TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download") 383 TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action") 384 } 385 } 386 } 387 Column( 388 modifier = Modifier.weight(1f), 389 ) { 390 Row( 391 modifier = Modifier.fillMaxWidth(), 392 verticalAlignment = Alignment.CenterVertically 393 ) { 394 Text(task.title, style = MaterialTheme.typography.bodyMedium) 395 task.author?.takeIf { it != "null" }?.let { 396 Spacer(modifier = Modifier.width(5.dp)) 397 Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 398 } 399 } 400 Text(task.hash, style = MaterialTheme.typography.labelSmall) 401 Column( 402 modifier = Modifier.padding(top = 5.dp), 403 verticalArrangement = Arrangement.spacedBy(5.dp) 404 ) { 405 if (taskStatus.isFinalStage()) { 406 if (taskStatus != TaskStatus.SUCCESS) { 407 Text("$taskStatus", style = MaterialTheme.typography.bodySmall) 408 } 409 } else { 410 taskProgressLabel?.let { 411 Text(it, style = MaterialTheme.typography.bodySmall) 412 } 413 if (taskProgress != -1) { 414 LinearProgressIndicator( 415 progress = { taskProgress.toFloat() / 100f }, 416 strokeCap = StrokeCap.Round, 417 ) 418 } else { 419 task.extra?.let { 420 Text(it, style = MaterialTheme.typography.bodySmall) 421 } 422 } 423 } 424 } 425 } 426 427 Column { 428 if (pendingTask != null && !taskStatus.isFinalStage()) { 429 FilledIconButton(onClick = { 430 runCatching { 431 pendingTask.cancel() 432 }.onFailure { throwable -> 433 context.log.error("Failed to cancel task $pendingTask", throwable) 434 } 435 }) { 436 Icon(Icons.Filled.Close, contentDescription = "Cancel") 437 } 438 } else { 439 when (taskStatus) { 440 TaskStatus.SUCCESS -> Icon(Icons.Filled.Check, contentDescription = "Success", tint = MaterialTheme.colorScheme.primary) 441 TaskStatus.FAILURE -> Icon(Icons.Filled.Error, contentDescription = "Failure", tint = MaterialTheme.colorScheme.error) 442 TaskStatus.CANCELLED -> Icon(Icons.Filled.Cancel, contentDescription = "Cancelled", tint = MaterialTheme.colorScheme.error) 443 else -> {} 444 } 445 } 446 } 447 } 448 } 449 } 450 451 override val content: @Composable (NavBackStackEntry) -> Unit = { 452 val scrollState = rememberLazyListState() 453 val scope = rememberCoroutineScope() 454 recentTasks = remember { mutableStateListOf() } 455 var lastFetchedTaskId by remember { mutableStateOf(null as Long?) } 456 457 fun fetchNewRecentTasks() { 458 scope.launch(Dispatchers.IO) { 459 val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE, limit = 20) 460 if (tasks.isNotEmpty()) { 461 lastFetchedTaskId = tasks.keys.last() 462 val activeTaskIds = activeTasks.map { it.taskId } 463 recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values) 464 } 465 } 466 } 467 468 LaunchedEffect(Unit) { 469 fetchActiveTasks(this) 470 } 471 472 DisposableEffect(Unit) { 473 onDispose { 474 taskSelection.clear() 475 } 476 } 477 478 OnLifecycleEvent { _, event -> 479 if (event == Lifecycle.Event.ON_RESUME) { 480 fetchActiveTasks(scope) 481 } 482 } 483 484 LazyColumn( 485 state = scrollState, 486 modifier = Modifier.fillMaxSize() 487 ) { 488 item { 489 if (activeTasks.isEmpty() && recentTasks.isEmpty()) { 490 Column( 491 modifier = Modifier.fillMaxSize(), 492 horizontalAlignment = Alignment.CenterHorizontally, 493 verticalArrangement = Arrangement.Center 494 ) { 495 translation["no_tasks"].let { 496 Icon(Icons.Filled.CheckCircle, contentDescription = it, tint = MaterialTheme.colorScheme.primary) 497 Text(it, style = MaterialTheme.typography.bodyLarge) 498 } 499 } 500 } 501 } 502 items(activeTasks, key = { it.taskId }) {pendingTask -> 503 TaskCard(modifier = Modifier.padding(8.dp), pendingTask.task, pendingTask = pendingTask) 504 } 505 items(recentTasks, key = { it.hash }) { task -> 506 TaskCard(modifier = Modifier.padding(8.dp), task) 507 } 508 item { 509 Spacer(modifier = Modifier.height(40.dp)) 510 LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { 511 fetchNewRecentTasks() 512 } 513 } 514 } 515 } 516 }