mirror of
https://github.com/rhunk/SnapEnhance.git
synced 2025-05-28 12:30:12 +02:00
feat(app/tasks): merge videos
This commit is contained in:
parent
a7f4f1cdaf
commit
04b70431c7
@ -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
|
||||
))
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 = {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
activeTasks = listOf()
|
||||
context.taskManager.getActiveTasks().clear()
|
||||
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()
|
||||
}
|
||||
}
|
||||
) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
|
@ -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",
|
||||
|
@ -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 {
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
),
|
||||
|
Loading…
x
Reference in New Issue
Block a user