commit 04b70431c7b384bae81a79ebbdc9675046a9a643
parent a7f4f1cdafc07f7a49f0d2aba56343dfc5f13455
Author: rhunk <101876869+rhunk@users.noreply.github.com>
Date:   Sat, 30 Dec 2023 16:30:05 +0100

feat(app/tasks): merge videos

Diffstat:
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt | 32++++++++++----------------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mapp/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt | 3++-
Mapp/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt | 25++++++++++++++++++++++++-
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mapp/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/LoggedStories.kt | 4++--
Mcommon/src/main/assets/lang/en_US.json | 16++++++++++++++--
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/data/FileType.kt | 5+++++
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadRequest.kt | 4++--
Mcommon/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt | 24+++++++++++++++---------
Mcore/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt | 2+-
11 files changed, 406 insertions(+), 94 deletions(-)

diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/DownloadProcessor.kt @@ -87,27 +87,15 @@ class DownloadProcessor ( fallbackToast(it) } - private fun newFFMpegProcessor(pendingTask: PendingTask) = FFMpegProcessor( - logManager = remoteSideContext.log, - ffmpegOptions = remoteSideContext.config.root.downloader.ffmpegOptions, - onStatistics = { - pendingTask.updateProgress("Processing (frames=${it.videoFrameNumber}, fps=${it.videoFps}, time=${it.time}, bitrate=${it.bitrate}, speed=${it.speed})") - } - ) + private fun newFFMpegProcessor(pendingTask: PendingTask) = FFMpegProcessor.newFFMpegProcessor(remoteSideContext, pendingTask) @SuppressLint("UnspecifiedRegisterReceiverFlag") - private suspend fun saveMediaToGallery(pendingTask: PendingTask, inputFile: File, metadata: DownloadMetadata) { + suspend fun saveMediaToGallery(pendingTask: PendingTask, inputFile: File, metadata: DownloadMetadata) { if (coroutineContext.job.isCancelled) return runCatching { var fileType = FileType.fromFile(inputFile) - if (fileType == FileType.UNKNOWN) { - callbackOnFailure(translation.format("failed_gallery_toast", "error" to "Unknown media type"), null) - pendingTask.fail("Unknown media type") - return - } - if (fileType.isImage) { remoteSideContext.config.root.downloader.forceImageFormat.getNullable()?.let { format -> val bitmap = BitmapFactory.decodeFile(inputFile.absolutePath) ?: throw Exception("Failed to decode bitmap") @@ -154,9 +142,9 @@ class DownloadProcessor ( pendingTask.success() runCatching { - val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE") - mediaScanIntent.setData(outputFile.uri) - remoteSideContext.androidContext.sendBroadcast(mediaScanIntent) + remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply { + data = outputFile.uri + }) }.onFailure { remoteSideContext.log.error("Failed to scan media file", it) callbackOnFailure(translation.format("failed_gallery_toast", "error" to it.toString()), it.message) @@ -266,7 +254,7 @@ class DownloadProcessor ( val outputFile = File.createTempFile("voice_note", ".$format") newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( action = FFMpegProcessor.Action.AUDIO_CONVERSION, - input = media.file, + inputs = listOf(media.file), output = outputFile )) media.file.delete() @@ -303,7 +291,7 @@ class DownloadProcessor ( runCatching { newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( action = FFMpegProcessor.Action.DOWNLOAD_DASH, - input = dashPlaylistFile, + inputs = listOf(dashPlaylistFile), output = outputFile, startTime = dashOptions.offsetTime, duration = dashOptions.duration @@ -356,7 +344,8 @@ class DownloadProcessor ( val pendingTask = remoteSideContext.taskManager.createPendingTask( Task( type = TaskType.DOWNLOAD, - title = downloadMetadata.downloadSource + " (" + downloadMetadata.mediaAuthor + ")", + title = downloadMetadata.downloadSource, + author = downloadMetadata.mediaAuthor, hash = downloadMetadata.mediaIdentifier ) ).apply { @@ -406,7 +395,6 @@ class DownloadProcessor ( if (shouldMergeOverlay) { assert(downloadedMedias.size == 2) - //TODO: convert "mp4 images" into real images val media = downloadedMedias.entries.first { !it.key.isOverlay }.value val overlayMedia = downloadedMedias.entries.first { it.key.isOverlay }.value @@ -418,7 +406,7 @@ class DownloadProcessor ( newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( action = FFMpegProcessor.Action.MERGE_OVERLAY, - input = renamedMedia, + inputs = listOf(renamedMedia), output = mergedOverlay, overlay = renamedOverlayMedia )) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt b/app/src/main/kotlin/me/rhunk/snapenhance/download/FFMpegProcessor.kt @@ -1,35 +1,43 @@ package me.rhunk.snapenhance.download +import android.media.MediaMetadataRetriever import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegSession import com.arthenica.ffmpegkit.Level import com.arthenica.ffmpegkit.Statistics import kotlinx.coroutines.suspendCancellableCoroutine import me.rhunk.snapenhance.LogManager +import me.rhunk.snapenhance.RemoteSideContext import me.rhunk.snapenhance.common.config.impl.DownloaderConfig import me.rhunk.snapenhance.common.logger.LogLevel +import me.rhunk.snapenhance.task.PendingTask import java.io.File import java.util.concurrent.Executors -class ArgumentList : LinkedHashMap<String, MutableList<String>>() { +class ArgumentList { + private val arguments = mutableListOf<Pair<String, String>>() + operator fun plusAssign(stringPair: Pair<String, String>) { - val (key, value) = stringPair - if (this.containsKey(key)) { - this[key]!!.add(value) - } else { - this[key] = mutableListOf(value) - } + arguments += stringPair } operator fun plusAssign(key: String) { - this[key] = mutableListOf<String>().apply { - this += "" - } + arguments += key to "" } operator fun minusAssign(key: String) { - this.remove(key) + arguments.removeIf { it.first == key } + } + + operator fun get(key: String) = arguments.find { it.first == key }?.second + + fun forEach(action: (Pair<String, String>) -> Unit) { + arguments.forEach(action) + } + + fun clear() { + arguments.clear() } } @@ -41,16 +49,25 @@ class FFMpegProcessor( ) { companion object { private const val TAG = "ffmpeg-processor" + + fun newFFMpegProcessor(context: RemoteSideContext, pendingTask: PendingTask) = FFMpegProcessor( + logManager = context.log, + ffmpegOptions = context.config.root.downloader.ffmpegOptions, + onStatistics = { + pendingTask.updateProgress("Processing (frames=${it.videoFrameNumber}, fps=${it.videoFps}, time=${it.time}, bitrate=${it.bitrate}, speed=${it.speed})") + } + ) } enum class Action { DOWNLOAD_DASH, MERGE_OVERLAY, AUDIO_CONVERSION, + MERGE_MEDIA } data class Request( val action: Action, - val input: File, + val inputs: List<File>, val output: File, val overlay: File? = null, //only for MERGE_OVERLAY val startTime: Long? = null, //only for DOWNLOAD_DASH @@ -61,14 +78,8 @@ class FFMpegProcessor( private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> { val stringBuilder = StringBuilder() arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList -> - argumentList.forEach { (key, values) -> - values.forEach valueForEach@{ value -> - if (value.isEmpty()) { - stringBuilder.append("$key ") - return@valueForEach - } - stringBuilder.append("$key $value ") - } + argumentList.forEach { (key, value) -> + stringBuilder.append("$key ${value.takeIf { it.isNotEmpty() }?.plus(" ") ?: ""}") } } @@ -102,7 +113,9 @@ class FFMpegProcessor( } val inputArguments = ArgumentList().apply { - this += "-i" to args.input.absolutePath + args.inputs.forEach { file -> + this += "-i" to file.absolutePath + } } val outputArguments = ArgumentList().apply { @@ -133,6 +146,54 @@ class FFMpegProcessor( outputArguments -= "-c:v" } } + Action.MERGE_MEDIA -> { + inputArguments.clear() + val filesInfo = args.inputs.mapNotNull { file -> + runCatching { + MediaMetadataRetriever().apply { setDataSource(file.absolutePath) } + }.getOrNull()?.let { file to it } + } + + val (maxWidth, maxHeight) = filesInfo.maxByOrNull { (_, r) -> + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0 + }?.let { (_, r) -> + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() to + r.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull() + } ?: throw Exception("Failed to get video size") + + val filterFirstPart = StringBuilder() + val filterSecondPart = StringBuilder() + var containsNoSound = false + + filesInfo.forEachIndexed { index, (file, retriever) -> + filterFirstPart.append("[$index:v]scale=$maxWidth:$maxHeight,setsar=1[v$index];") + if (retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO) == "yes") { + filterSecondPart.append("[v$index][$index:a]") + } else { + containsNoSound = true + filterSecondPart.append("[v$index][${filesInfo.size}]") + } + inputArguments += "-i" to file.absolutePath + } + + if (containsNoSound) { + inputArguments += "-f" to "lavfi" + inputArguments += "-t" to "0.1" + inputArguments += "-i" to "anullsrc=channel_layout=stereo:sample_rate=44100" + } + + if (outputArguments["-c:a"] == "copy") { + outputArguments -= "-c:a" + } + + outputArguments += "-fps_mode" to "vfr" + + outputArguments += "-filter_complex" to "\"$filterFirstPart ${filterSecondPart}concat=n=${args.inputs.size}:v=1:a=1[vout][aout]\"" + outputArguments += "-map" to "\"[aout]\"" + outputArguments += "-map" to "\"[vout]\"" + + filesInfo.forEach { it.second.close() } + } } outputArguments += args.output.absolutePath newFFMpegTask(globalArguments, inputArguments, outputArguments) diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt b/app/src/main/kotlin/me/rhunk/snapenhance/task/PendingTask.kt @@ -44,6 +44,7 @@ data class PendingTaskListener( data class Task( val type: TaskType, val title: String, + val author: String?, val hash: String ) { var changeListener: () -> Unit = {} @@ -106,7 +107,7 @@ class PendingTask( } fun updateProgress(label: String, progress: Int = -1) { - _progress = progress + _progress = progress.coerceIn(-1, 100) progressLabel = label } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt b/app/src/main/kotlin/me/rhunk/snapenhance/task/TaskManager.kt @@ -26,6 +26,7 @@ class TaskManager( "id INTEGER PRIMARY KEY AUTOINCREMENT", "hash VARCHAR UNIQUE", "title VARCHAR(255) NOT NULL", + "author VARCHAR(255)", "type VARCHAR(255) NOT NULL", "status VARCHAR(255) NOT NULL", "extra TEXT" @@ -37,7 +38,12 @@ class TaskManager( private val activeTasks = mutableMapOf<Long, PendingTask>() private fun readTaskFromCursor(cursor: android.database.Cursor): Task { - val task = Task(TaskType.fromKey(cursor.getStringOrNull("type")!!), cursor.getStringOrNull("title")!!, cursor.getStringOrNull("hash")!!) + val task = Task( + type = TaskType.fromKey(cursor.getStringOrNull("type")!!), + title = cursor.getStringOrNull("title")!!, + author = cursor.getStringOrNull("author"), + hash = cursor.getStringOrNull("hash")!! + ) task.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!) task.extra = cursor.getStringOrNull("extra") task.changeListener = { @@ -60,6 +66,7 @@ class TaskManager( val result = taskDatabase.insert("tasks", null, ContentValues().apply { put("type", task.type.key) put("hash", task.hash) + put("author", task.author) put("title", task.title) put("status", task.status.key) put("extra", task.extra) @@ -91,6 +98,22 @@ class TaskManager( } } + fun removeTask(task: Task) { + runBlocking { + activeTasks.entries.find { it.value.task == task }?.let { + activeTasks.remove(it.key) + runCatching { + it.value.cancel() + }.onFailure { + remoteSideContext.log.warn("Failed to cancel task ${task.hash}") + } + } + launch(queueExecutor.asCoroutineDispatcher()) { + taskDatabase.execSQL("DELETE FROM tasks WHERE hash = ?", arrayOf(task.hash)) + } + } + } + fun createPendingTask(task: Task): PendingTask { val taskId = putNewTask(task) task.changeListener = { diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/TasksSection.kt @@ -1,5 +1,8 @@ package me.rhunk.snapenhance.ui.manager.sections + import android.content.Intent +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -10,12 +13,23 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.Lifecycle +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import me.rhunk.snapenhance.bridge.DownloadCallback +import me.rhunk.snapenhance.common.data.download.DownloadMetadata +import me.rhunk.snapenhance.common.data.download.MediaDownloadSource +import me.rhunk.snapenhance.common.data.download.createNewFilePath +import me.rhunk.snapenhance.common.util.ktx.longHashCode +import me.rhunk.snapenhance.download.DownloadProcessor +import me.rhunk.snapenhance.download.FFMpegProcessor import me.rhunk.snapenhance.task.PendingTask import me.rhunk.snapenhance.task.PendingTaskListener import me.rhunk.snapenhance.task.Task @@ -23,41 +37,201 @@ import me.rhunk.snapenhance.task.TaskStatus import me.rhunk.snapenhance.task.TaskType import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.util.OnLifecycleEvent +import java.io.File +import java.util.UUID +import kotlin.math.absoluteValue class TasksSection : Section() { private var activeTasks by mutableStateOf(listOf<PendingTask>()) private lateinit var recentTasks: MutableList<Task> + private val taskSelection = mutableStateListOf<Pair<Task, DocumentFile?>>() + + private fun fetchActiveTasks(scope: CoroutineScope = context.coroutineScope) { + scope.launch(Dispatchers.IO) { + activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList() + } + } + + private fun mergeSelection(selection: List<Pair<Task, DocumentFile>>) { + val firstTask = selection.first().first + + val taskHash = UUID.randomUUID().toString().longHashCode().absoluteValue.toString(16) + val pendingTask = context.taskManager.createPendingTask( + Task(TaskType.DOWNLOAD, "Merge ${selection.size} files", firstTask.author, taskHash) + ) + pendingTask.status = TaskStatus.RUNNING + fetchActiveTasks() + + context.coroutineScope.launch { + val filesToMerge = mutableListOf<File>() + + selection.forEach { (task, documentFile) -> + val tempFile = File.createTempFile(task.hash, "." + documentFile.name?.substringAfterLast("."), context.androidContext.cacheDir).also { + it.deleteOnExit() + } + + runCatching { + pendingTask.updateProgress("Copying ${documentFile.name}") + context.androidContext.contentResolver.openInputStream(documentFile.uri)?.use { inputStream -> + //copy with progress + val length = documentFile.length().toFloat() + tempFile.outputStream().use { outputStream -> + val buffer = ByteArray(16 * 1024) + var read: Int + while (inputStream.read(buffer).also { read = it } != -1) { + outputStream.write(buffer, 0, read) + pendingTask.updateProgress("Copying ${documentFile.name}", (outputStream.channel.position().toFloat() / length * 100f).toInt()) + } + outputStream.flush() + filesToMerge.add(tempFile) + } + } + }.onFailure { + pendingTask.fail("Failed to copy file $documentFile to $tempFile") + filesToMerge.forEach { it.delete() } + return@launch + } + } + + val mergedFile = File.createTempFile("merged", ".mp4", context.androidContext.cacheDir).also { + it.deleteOnExit() + } + + runCatching { + context.shortToast("Merging ${filesToMerge.size} files") + FFMpegProcessor.newFFMpegProcessor(context, pendingTask).execute( + FFMpegProcessor.Request(FFMpegProcessor.Action.MERGE_MEDIA, filesToMerge, mergedFile) + ) + DownloadProcessor(context, object: DownloadCallback.Default() { + override fun onSuccess(outputPath: String) { + context.log.verbose("Merged files to $outputPath") + } + }).saveMediaToGallery(pendingTask, mergedFile, DownloadMetadata( + mediaIdentifier = taskHash, + outputPath = createNewFilePath( + context.config.root, + taskHash, + downloadSource = MediaDownloadSource.MERGED, + mediaAuthor = firstTask.author, + creationTimestamp = System.currentTimeMillis() + ), + mediaAuthor = firstTask.author, + downloadSource = MediaDownloadSource.MERGED.translate(context.translation), + iconUrl = null + )) + }.onFailure { + context.log.error("Failed to merge files", it) + pendingTask.fail(it.message ?: "Failed to merge files") + }.onSuccess { + pendingTask.success() + } + filesToMerge.forEach { it.delete() } + mergedFile.delete() + }.also { + pendingTask.addListener(PendingTaskListener(onCancel = { it.cancel() })) + } + } + @Composable override fun TopBarActions(rowScope: RowScope) { var showConfirmDialog by remember { mutableStateOf(false) } + if (taskSelection.size == 1 && taskSelection.firstOrNull()?.second?.exists() == true) { + taskSelection.firstOrNull()?.second?.takeIf { it.exists() }?.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.Filled.OpenInNew, contentDescription = "Open") + } + } + } + + if (taskSelection.size > 1 && taskSelection.all { it.second?.type?.contains("video") == true }) { + IconButton(onClick = { + mergeSelection(taskSelection.toList().also { + taskSelection.clear() + }.map { it.first to it.second!! }) + }) { + Icon(Icons.Filled.Merge, contentDescription = "Merge") + } + } + IconButton(onClick = { showConfirmDialog = true }) { - Icon(Icons.Filled.Delete, contentDescription = "Clear all tasks") + Icon(Icons.Filled.Delete, contentDescription = "Clear tasks") } if (showConfirmDialog) { + var alsoDeleteFiles by remember { mutableStateOf(false) } + AlertDialog( onDismissRequest = { showConfirmDialog = false }, - title = { Text("Clear all tasks") }, - text = { Text("Are you sure you want to clear all tasks?") }, + title = { + if (taskSelection.isNotEmpty()) { + Text("Remove ${taskSelection.size} tasks?") + } else { + Text("Remove all tasks?") + } + }, + text = { + Column { + if (taskSelection.isNotEmpty()) { + Text("Are you sure you want to remove selected tasks?") + Row ( + modifier = Modifier.padding(top = 10.dp).fillMaxWidth().clickable { + alsoDeleteFiles = !alsoDeleteFiles + }, + horizontalArrangement = Arrangement.spacedBy(5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox(checked = alsoDeleteFiles, onCheckedChange = { + alsoDeleteFiles = it + }) + Text("Also delete files") + } + } else { + Text("Are you sure you want to remove all tasks?") + } + } + }, confirmButton = { Button( onClick = { - context.taskManager.clearAllTasks() - recentTasks.clear() - activeTasks.forEach { - runCatching { - it.cancel() - }.onFailure { throwable -> - context.log.error("Failed to cancel task $it", throwable) + showConfirmDialog = false + + if (taskSelection.isNotEmpty()) { + taskSelection.forEach { (task, documentFile) -> + context.taskManager.removeTask(task) + recentTasks.remove(task) + if (alsoDeleteFiles) { + documentFile?.delete() + } + } + activeTasks = activeTasks.filter { task -> !taskSelection.map { it.first }.contains(task.task) } + taskSelection.clear() + } else { + context.taskManager.clearAllTasks() + recentTasks.clear() + activeTasks.forEach { + runCatching { + it.cancel() + }.onFailure { throwable -> + context.log.error("Failed to cancel task $it", throwable) + } } + activeTasks = listOf() + context.taskManager.getActiveTasks().clear() } - activeTasks = listOf() - context.taskManager.getActiveTasks().clear() - showConfirmDialog = false } ) { Text("Yes") @@ -81,6 +255,16 @@ class TasksSection : Section() { var taskStatus by remember { mutableStateOf(task.status) } var taskProgressLabel by remember { mutableStateOf<String?>(null) } var taskProgress by remember { mutableIntStateOf(-1) } + val isSelected by remember { derivedStateOf { taskSelection.any { it.first == task } } } + var documentFile by remember { mutableStateOf<DocumentFile?>(null) } + var isDocumentFileReadable by remember { mutableStateOf(true) } + + LaunchedEffect(taskStatus.key) { + launch(Dispatchers.IO) { + documentFile = DocumentFile.fromSingleUri(context.androidContext, task.extra?.toUri() ?: return@launch) + isDocumentFileReadable = documentFile?.canRead() ?: false + } + } val listener = remember { PendingTaskListener( onStateChange = { @@ -102,7 +286,19 @@ class TasksSection : Section() { } } - OutlinedCard(modifier = modifier) { + OutlinedCard(modifier = modifier.clickable { + if (isSelected) { + taskSelection.removeIf { it.first == task } + return@clickable + } + taskSelection.add(task to documentFile) + }.let { + if (isSelected) { + it + .border(2.dp, MaterialTheme.colorScheme.primary) + .clip(MaterialTheme.shapes.medium) + } else it + }) { Row( modifier = Modifier.padding(15.dp), verticalAlignment = Alignment.CenterVertically @@ -110,15 +306,35 @@ class TasksSection : Section() { Column( modifier = Modifier.padding(end = 15.dp) ) { - when (task.type) { - TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download") - TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action") + documentFile?.let { file -> + val mimeType = file.type ?: "" + when { + !isDocumentFileReadable -> Icon(Icons.Filled.DeleteOutline, contentDescription = "File not found") + mimeType.contains("image") -> Icon(Icons.Filled.Image, contentDescription = "Image") + mimeType.contains("video") -> Icon(Icons.Filled.Videocam, contentDescription = "Video") + mimeType.contains("audio") -> Icon(Icons.Filled.MusicNote, contentDescription = "Audio") + else -> Icon(Icons.Filled.FileCopy, contentDescription = "File") + } + } ?: run { + when (task.type) { + TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download") + TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action") + } } } Column( modifier = Modifier.weight(1f), ) { - Text(task.title, style = MaterialTheme.typography.bodyLarge) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text(task.title, style = MaterialTheme.typography.bodyMedium) + task.author?.takeIf { it != "null" }?.let { + Spacer(modifier = Modifier.width(5.dp)) + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } Text(task.hash, style = MaterialTheme.typography.labelSmall) Column( modifier = Modifier.padding(top = 5.dp), @@ -183,27 +399,25 @@ class TasksSection : Section() { val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE) if (tasks.isNotEmpty()) { lastFetchedTaskId = tasks.keys.last() - scope.launch { - val activeTaskIds = activeTasks.map { it.taskId } - recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values) - } + val activeTaskIds = activeTasks.map { it.taskId } + recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values) } } } - fun fetchActiveTasks() { - activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList() + LaunchedEffect(Unit) { + fetchActiveTasks(this) } - LaunchedEffect(Unit) { - fetchActiveTasks() + DisposableEffect(Unit) { + onDispose { + taskSelection.clear() + } } OnLifecycleEvent { _, event -> if (event == Lifecycle.Event.ON_RESUME) { - scope.launch { - fetchActiveTasks() - } + fetchActiveTasks(scope) } } @@ -218,12 +432,14 @@ class TasksSection : Section() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Icon(Icons.Filled.CheckCircle, contentDescription = "No tasks", tint = MaterialTheme.colorScheme.primary) - Text("No tasks", style = MaterialTheme.typography.bodyLarge) + context.translation["manager.sections.tasks.no_tasks"].let { + Icon(Icons.Filled.CheckCircle, contentDescription = it, tint = MaterialTheme.colorScheme.primary) + Text(it, style = MaterialTheme.typography.bodyLarge) + } } } } - items(activeTasks, key = { it.task.hash }) {pendingTask -> + items(activeTasks, key = { it.taskId }) {pendingTask -> TaskCard(modifier = Modifier.padding(8.dp), pendingTask.task, pendingTask = pendingTask) } items(recentTasks, key = { it.hash }) { task -> @@ -231,7 +447,7 @@ class TasksSection : Section() { } item { Spacer(modifier = Modifier.height(20.dp)) - SideEffect { + LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) { fetchNewRecentTasks() } } diff --git a/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/LoggedStories.kt b/app/src/main/kotlin/me/rhunk/snapenhance/ui/manager/sections/social/LoggedStories.kt @@ -118,7 +118,7 @@ fun LoggedStories( Button(onClick = { val mediaAuthor = friendInfo?.mutableUsername ?: userId - val uniqueHash = selectedStory?.url?.longHashCode()?.absoluteValue?.toString(16) ?: UUID.randomUUID().toString() + val uniqueHash = (selectedStory?.url ?: UUID.randomUUID().toString()).longHashCode().absoluteValue.toString(16) DownloadProcessor( remoteSideContext = context, @@ -150,7 +150,7 @@ fun LoggedStories( ), iconUrl = null, mediaAuthor = friendInfo?.mutableUsername ?: userId, - downloadSource = MediaDownloadSource.STORY_LOGGER.key + downloadSource = MediaDownloadSource.STORY_LOGGER.translate(context.translation), )) }) { Text(text = "Download") diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json @@ -38,8 +38,8 @@ "export_logs_button": "Export Logs" } }, - "downloads": { - "empty_download_list": "(empty)" + "tasks": { + "no_tasks": "No tasks" }, "features": { "disabled": "Disabled" @@ -899,6 +899,18 @@ "STATUS_COUNTDOWN": "Countdown" }, + "media_download_source": { + "none": "None", + "pending": "Pending", + "chat_media": "Chat Media", + "story": "Story", + "public_story": "Public Story", + "spotlight": "Spotlight", + "profile_picture": "Profile Picture", + "story_logger": "Story Logger", + "merged": "Merged" + }, + "chat_action_menu": { "preview_button": "Preview", "download_button": "Download", diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/FileType.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/FileType.kt @@ -13,6 +13,8 @@ enum class FileType( GIF("gif", "image/gif", false, false, false), PNG("png", "image/png", false, true, false), MP4("mp4", "video/mp4", true, false, false), + MKV("mkv", "video/mkv", true, false, false), + AVI("avi", "video/avi", true, false, false), MP3("mp3", "audio/mp3",false, false, true), OPUS("opus", "audio/opus", false, false, true), AAC("aac", "audio/aac", false, false, true), @@ -34,6 +36,9 @@ enum class FileType( "4f676753" to OPUS, "fff15" to AAC, "ffd8ff" to JPG, + "47494638" to GIF, + "1a45dfa3" to MKV, + "52494646" to AVI, ) fun fromString(string: String?): FileType { diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadRequest.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/DownloadRequest.kt @@ -40,12 +40,12 @@ fun createNewFilePath( config: RootConfig, hexHash: String, downloadSource: MediaDownloadSource, - mediaAuthor: String, + mediaAuthor: String?, creationTimestamp: Long? ): String { val pathFormat by config.downloader.pathFormat val customPathFormat by config.downloader.customPathFormat - val sanitizedMediaAuthor = mediaAuthor.sanitizeForPath().ifEmpty { hexHash } + val sanitizedMediaAuthor = mediaAuthor?.sanitizeForPath() ?: hexHash val currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(creationTimestamp ?: System.currentTimeMillis()) val finalPath = StringBuilder() diff --git a/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt b/common/src/main/kotlin/me/rhunk/snapenhance/common/data/download/MediaDownloadSource.kt @@ -1,25 +1,31 @@ package me.rhunk.snapenhance.common.data.download +import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper + enum class MediaDownloadSource( val key: String, - val displayName: String = key, val pathName: String = key, val ignoreFilter: Boolean = false ) { - NONE("none", "None", ignoreFilter = true), - PENDING("pending", "Pending", ignoreFilter = true), - CHAT_MEDIA("chat_media", "Chat Media", "chat_media"), - STORY("story", "Story", "story"), - PUBLIC_STORY("public_story", "Public Story", "public_story"), - SPOTLIGHT("spotlight", "Spotlight", "spotlight"), - PROFILE_PICTURE("profile_picture", "Profile Picture", "profile_picture"), - STORY_LOGGER("story_logger", "Story Logger", "story_logger"); + NONE("none", ignoreFilter = true), + PENDING("pending", ignoreFilter = true), + CHAT_MEDIA("chat_media", "chat_media"), + STORY("story", "story"), + PUBLIC_STORY("public_story", "public_story"), + SPOTLIGHT("spotlight", "spotlight"), + PROFILE_PICTURE("profile_picture", "profile_picture"), + STORY_LOGGER("story_logger", "story_logger"), + MERGED("merged", "merged"); fun matches(source: String?): Boolean { if (source == null) return false return source.contains(key, ignoreCase = true) } + fun translate(translation: LocaleWrapper): String { + return translation["media_download_source.$key"] + } + companion object { fun fromKey(key: String?): MediaDownloadSource { if (key == null) return NONE diff --git a/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt b/core/src/main/kotlin/me/rhunk/snapenhance/core/features/impl/downloader/MediaDownloader.kt @@ -102,7 +102,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp metadata = DownloadMetadata( mediaIdentifier = generatedHash, mediaAuthor = mediaAuthor, - downloadSource = downloadSource.key, + downloadSource = downloadSource.translate(context.translation), iconUrl = iconUrl, outputPath = outputPath ),