feat(app/tasks): merge videos

This commit is contained in:
rhunk 2023-12-30 16:30:05 +01:00
parent a7f4f1cdaf
commit 04b70431c7
11 changed files with 407 additions and 95 deletions

View File

@ -87,27 +87,15 @@ class DownloadProcessor (
fallbackToast(it) fallbackToast(it)
} }
private fun newFFMpegProcessor(pendingTask: PendingTask) = FFMpegProcessor( private fun newFFMpegProcessor(pendingTask: PendingTask) = FFMpegProcessor.newFFMpegProcessor(remoteSideContext, pendingTask)
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})")
}
)
@SuppressLint("UnspecifiedRegisterReceiverFlag") @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 if (coroutineContext.job.isCancelled) return
runCatching { runCatching {
var fileType = FileType.fromFile(inputFile) 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) { if (fileType.isImage) {
remoteSideContext.config.root.downloader.forceImageFormat.getNullable()?.let { format -> remoteSideContext.config.root.downloader.forceImageFormat.getNullable()?.let { format ->
val bitmap = BitmapFactory.decodeFile(inputFile.absolutePath) ?: throw Exception("Failed to decode bitmap") val bitmap = BitmapFactory.decodeFile(inputFile.absolutePath) ?: throw Exception("Failed to decode bitmap")
@ -154,9 +142,9 @@ class DownloadProcessor (
pendingTask.success() pendingTask.success()
runCatching { runCatching {
val mediaScanIntent = Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE") remoteSideContext.androidContext.sendBroadcast(Intent("android.intent.action.MEDIA_SCANNER_SCAN_FILE").apply {
mediaScanIntent.setData(outputFile.uri) data = outputFile.uri
remoteSideContext.androidContext.sendBroadcast(mediaScanIntent) })
}.onFailure { }.onFailure {
remoteSideContext.log.error("Failed to scan media file", it) remoteSideContext.log.error("Failed to scan media file", it)
callbackOnFailure(translation.format("failed_gallery_toast", "error" to it.toString()), it.message) 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") val outputFile = File.createTempFile("voice_note", ".$format")
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.AUDIO_CONVERSION, action = FFMpegProcessor.Action.AUDIO_CONVERSION,
input = media.file, inputs = listOf(media.file),
output = outputFile output = outputFile
)) ))
media.file.delete() media.file.delete()
@ -303,7 +291,7 @@ class DownloadProcessor (
runCatching { runCatching {
newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request( newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.DOWNLOAD_DASH, action = FFMpegProcessor.Action.DOWNLOAD_DASH,
input = dashPlaylistFile, inputs = listOf(dashPlaylistFile),
output = outputFile, output = outputFile,
startTime = dashOptions.offsetTime, startTime = dashOptions.offsetTime,
duration = dashOptions.duration duration = dashOptions.duration
@ -356,7 +344,8 @@ class DownloadProcessor (
val pendingTask = remoteSideContext.taskManager.createPendingTask( val pendingTask = remoteSideContext.taskManager.createPendingTask(
Task( Task(
type = TaskType.DOWNLOAD, type = TaskType.DOWNLOAD,
title = downloadMetadata.downloadSource + " (" + downloadMetadata.mediaAuthor + ")", title = downloadMetadata.downloadSource,
author = downloadMetadata.mediaAuthor,
hash = downloadMetadata.mediaIdentifier hash = downloadMetadata.mediaIdentifier
) )
).apply { ).apply {
@ -406,7 +395,6 @@ class DownloadProcessor (
if (shouldMergeOverlay) { if (shouldMergeOverlay) {
assert(downloadedMedias.size == 2) assert(downloadedMedias.size == 2)
//TODO: convert "mp4 images" into real images
val media = downloadedMedias.entries.first { !it.key.isOverlay }.value val media = downloadedMedias.entries.first { !it.key.isOverlay }.value
val overlayMedia = 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( newFFMpegProcessor(pendingTask).execute(FFMpegProcessor.Request(
action = FFMpegProcessor.Action.MERGE_OVERLAY, action = FFMpegProcessor.Action.MERGE_OVERLAY,
input = renamedMedia, inputs = listOf(renamedMedia),
output = mergedOverlay, output = mergedOverlay,
overlay = renamedOverlayMedia overlay = renamedOverlayMedia
)) ))

View File

@ -1,35 +1,43 @@
package me.rhunk.snapenhance.download package me.rhunk.snapenhance.download
import android.media.MediaMetadataRetriever
import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegSession import com.arthenica.ffmpegkit.FFmpegSession
import com.arthenica.ffmpegkit.Level import com.arthenica.ffmpegkit.Level
import com.arthenica.ffmpegkit.Statistics import com.arthenica.ffmpegkit.Statistics
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import me.rhunk.snapenhance.LogManager import me.rhunk.snapenhance.LogManager
import me.rhunk.snapenhance.RemoteSideContext
import me.rhunk.snapenhance.common.config.impl.DownloaderConfig import me.rhunk.snapenhance.common.config.impl.DownloaderConfig
import me.rhunk.snapenhance.common.logger.LogLevel import me.rhunk.snapenhance.common.logger.LogLevel
import me.rhunk.snapenhance.task.PendingTask
import java.io.File import java.io.File
import java.util.concurrent.Executors 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>) { operator fun plusAssign(stringPair: Pair<String, String>) {
val (key, value) = stringPair arguments += stringPair
if (this.containsKey(key)) {
this[key]!!.add(value)
} else {
this[key] = mutableListOf(value)
}
} }
operator fun plusAssign(key: String) { operator fun plusAssign(key: String) {
this[key] = mutableListOf<String>().apply { arguments += key to ""
this += ""
}
} }
operator fun minusAssign(key: String) { 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 { companion object {
private const val TAG = "ffmpeg-processor" 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 { enum class Action {
DOWNLOAD_DASH, DOWNLOAD_DASH,
MERGE_OVERLAY, MERGE_OVERLAY,
AUDIO_CONVERSION, AUDIO_CONVERSION,
MERGE_MEDIA
} }
data class Request( data class Request(
val action: Action, val action: Action,
val input: File, val inputs: List<File>,
val output: File, val output: File,
val overlay: File? = null, //only for MERGE_OVERLAY val overlay: File? = null, //only for MERGE_OVERLAY
val startTime: Long? = null, //only for DOWNLOAD_DASH 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> { private suspend fun newFFMpegTask(globalArguments: ArgumentList, inputArguments: ArgumentList, outputArguments: ArgumentList) = suspendCancellableCoroutine<FFmpegSession> {
val stringBuilder = StringBuilder() val stringBuilder = StringBuilder()
arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList -> arrayOf(globalArguments, inputArguments, outputArguments).forEach { argumentList ->
argumentList.forEach { (key, values) -> argumentList.forEach { (key, value) ->
values.forEach valueForEach@{ value -> stringBuilder.append("$key ${value.takeIf { it.isNotEmpty() }?.plus(" ") ?: ""}")
if (value.isEmpty()) {
stringBuilder.append("$key ")
return@valueForEach
}
stringBuilder.append("$key $value ")
}
} }
} }
@ -102,7 +113,9 @@ class FFMpegProcessor(
} }
val inputArguments = ArgumentList().apply { val inputArguments = ArgumentList().apply {
this += "-i" to args.input.absolutePath args.inputs.forEach { file ->
this += "-i" to file.absolutePath
}
} }
val outputArguments = ArgumentList().apply { val outputArguments = ArgumentList().apply {
@ -133,6 +146,54 @@ class FFMpegProcessor(
outputArguments -= "-c:v" 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 outputArguments += args.output.absolutePath
newFFMpegTask(globalArguments, inputArguments, outputArguments) newFFMpegTask(globalArguments, inputArguments, outputArguments)

View File

@ -44,6 +44,7 @@ data class PendingTaskListener(
data class Task( data class Task(
val type: TaskType, val type: TaskType,
val title: String, val title: String,
val author: String?,
val hash: String val hash: String
) { ) {
var changeListener: () -> Unit = {} var changeListener: () -> Unit = {}
@ -106,7 +107,7 @@ class PendingTask(
} }
fun updateProgress(label: String, progress: Int = -1) { fun updateProgress(label: String, progress: Int = -1) {
_progress = progress _progress = progress.coerceIn(-1, 100)
progressLabel = label progressLabel = label
} }

View File

@ -26,6 +26,7 @@ class TaskManager(
"id INTEGER PRIMARY KEY AUTOINCREMENT", "id INTEGER PRIMARY KEY AUTOINCREMENT",
"hash VARCHAR UNIQUE", "hash VARCHAR UNIQUE",
"title VARCHAR(255) NOT NULL", "title VARCHAR(255) NOT NULL",
"author VARCHAR(255)",
"type VARCHAR(255) NOT NULL", "type VARCHAR(255) NOT NULL",
"status VARCHAR(255) NOT NULL", "status VARCHAR(255) NOT NULL",
"extra TEXT" "extra TEXT"
@ -37,7 +38,12 @@ class TaskManager(
private val activeTasks = mutableMapOf<Long, PendingTask>() private val activeTasks = mutableMapOf<Long, PendingTask>()
private fun readTaskFromCursor(cursor: android.database.Cursor): Task { 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.status = TaskStatus.fromKey(cursor.getStringOrNull("status")!!)
task.extra = cursor.getStringOrNull("extra") task.extra = cursor.getStringOrNull("extra")
task.changeListener = { task.changeListener = {
@ -60,6 +66,7 @@ class TaskManager(
val result = taskDatabase.insert("tasks", null, ContentValues().apply { val result = taskDatabase.insert("tasks", null, ContentValues().apply {
put("type", task.type.key) put("type", task.type.key)
put("hash", task.hash) put("hash", task.hash)
put("author", task.author)
put("title", task.title) put("title", task.title)
put("status", task.status.key) put("status", task.status.key)
put("extra", task.extra) 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 { fun createPendingTask(task: Task): PendingTask {
val taskId = putNewTask(task) val taskId = putNewTask(task)
task.changeListener = { task.changeListener = {

View File

@ -1,5 +1,8 @@
package me.rhunk.snapenhance.ui.manager.sections 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.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
@ -10,12 +13,23 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
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.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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.PendingTask
import me.rhunk.snapenhance.task.PendingTaskListener import me.rhunk.snapenhance.task.PendingTaskListener
import me.rhunk.snapenhance.task.Task 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.task.TaskType
import me.rhunk.snapenhance.ui.manager.Section import me.rhunk.snapenhance.ui.manager.Section
import me.rhunk.snapenhance.ui.util.OnLifecycleEvent import me.rhunk.snapenhance.ui.util.OnLifecycleEvent
import java.io.File
import java.util.UUID
import kotlin.math.absoluteValue
class TasksSection : Section() { class TasksSection : Section() {
private var activeTasks by mutableStateOf(listOf<PendingTask>()) private var activeTasks by mutableStateOf(listOf<PendingTask>())
private lateinit var recentTasks: MutableList<Task> 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 @Composable
override fun TopBarActions(rowScope: RowScope) { override fun TopBarActions(rowScope: RowScope) {
var showConfirmDialog by remember { mutableStateOf(false) } 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 = { IconButton(onClick = {
showConfirmDialog = true showConfirmDialog = true
}) { }) {
Icon(Icons.Filled.Delete, contentDescription = "Clear all tasks") Icon(Icons.Filled.Delete, contentDescription = "Clear tasks")
} }
if (showConfirmDialog) { if (showConfirmDialog) {
var alsoDeleteFiles by remember { mutableStateOf(false) }
AlertDialog( AlertDialog(
onDismissRequest = { showConfirmDialog = false }, onDismissRequest = { showConfirmDialog = false },
title = { Text("Clear all tasks") }, title = {
text = { Text("Are you sure you want to clear all tasks?") }, 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 = { confirmButton = {
Button( Button(
onClick = { 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 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") Text("Yes")
@ -81,6 +255,16 @@ class TasksSection : Section() {
var taskStatus by remember { mutableStateOf(task.status) } var taskStatus by remember { mutableStateOf(task.status) }
var taskProgressLabel by remember { mutableStateOf<String?>(null) } var taskProgressLabel by remember { mutableStateOf<String?>(null) }
var taskProgress by remember { mutableIntStateOf(-1) } 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( val listener = remember { PendingTaskListener(
onStateChange = { 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( Row(
modifier = Modifier.padding(15.dp), modifier = Modifier.padding(15.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@ -110,15 +306,35 @@ class TasksSection : Section() {
Column( Column(
modifier = Modifier.padding(end = 15.dp) modifier = Modifier.padding(end = 15.dp)
) { ) {
when (task.type) { documentFile?.let { file ->
TaskType.DOWNLOAD -> Icon(Icons.Filled.Download, contentDescription = "Download") val mimeType = file.type ?: ""
TaskType.CHAT_ACTION -> Icon(Icons.Filled.ChatBubble, contentDescription = "Chat Action") 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( Column(
modifier = Modifier.weight(1f), 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) Text(task.hash, style = MaterialTheme.typography.labelSmall)
Column( Column(
modifier = Modifier.padding(top = 5.dp), modifier = Modifier.padding(top = 5.dp),
@ -183,27 +399,25 @@ class TasksSection : Section() {
val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE) val tasks = context.taskManager.fetchStoredTasks(lastFetchedTaskId ?: Long.MAX_VALUE)
if (tasks.isNotEmpty()) { if (tasks.isNotEmpty()) {
lastFetchedTaskId = tasks.keys.last() lastFetchedTaskId = tasks.keys.last()
scope.launch { val activeTaskIds = activeTasks.map { it.taskId }
val activeTaskIds = activeTasks.map { it.taskId } recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values)
recentTasks.addAll(tasks.filter { it.key !in activeTaskIds }.values)
}
} }
} }
} }
fun fetchActiveTasks() { LaunchedEffect(Unit) {
activeTasks = context.taskManager.getActiveTasks().values.sortedByDescending { it.taskId }.toMutableList() fetchActiveTasks(this)
} }
LaunchedEffect(Unit) { DisposableEffect(Unit) {
fetchActiveTasks() onDispose {
taskSelection.clear()
}
} }
OnLifecycleEvent { _, event -> OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_RESUME) { if (event == Lifecycle.Event.ON_RESUME) {
scope.launch { fetchActiveTasks(scope)
fetchActiveTasks()
}
} }
} }
@ -218,12 +432,14 @@ class TasksSection : Section() {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Icon(Icons.Filled.CheckCircle, contentDescription = "No tasks", tint = MaterialTheme.colorScheme.primary) context.translation["manager.sections.tasks.no_tasks"].let {
Text("No tasks", style = MaterialTheme.typography.bodyLarge) 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) TaskCard(modifier = Modifier.padding(8.dp), pendingTask.task, pendingTask = pendingTask)
} }
items(recentTasks, key = { it.hash }) { task -> items(recentTasks, key = { it.hash }) { task ->
@ -231,7 +447,7 @@ class TasksSection : Section() {
} }
item { item {
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
SideEffect { LaunchedEffect(remember { derivedStateOf { scrollState.firstVisibleItemIndex } }) {
fetchNewRecentTasks() fetchNewRecentTasks()
} }
} }

View File

@ -118,7 +118,7 @@ fun LoggedStories(
Button(onClick = { Button(onClick = {
val mediaAuthor = friendInfo?.mutableUsername ?: userId 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( DownloadProcessor(
remoteSideContext = context, remoteSideContext = context,
@ -150,7 +150,7 @@ fun LoggedStories(
), ),
iconUrl = null, iconUrl = null,
mediaAuthor = friendInfo?.mutableUsername ?: userId, mediaAuthor = friendInfo?.mutableUsername ?: userId,
downloadSource = MediaDownloadSource.STORY_LOGGER.key downloadSource = MediaDownloadSource.STORY_LOGGER.translate(context.translation),
)) ))
}) { }) {
Text(text = "Download") Text(text = "Download")

View File

@ -38,8 +38,8 @@
"export_logs_button": "Export Logs" "export_logs_button": "Export Logs"
} }
}, },
"downloads": { "tasks": {
"empty_download_list": "(empty)" "no_tasks": "No tasks"
}, },
"features": { "features": {
"disabled": "Disabled" "disabled": "Disabled"
@ -899,6 +899,18 @@
"STATUS_COUNTDOWN": "Countdown" "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": { "chat_action_menu": {
"preview_button": "Preview", "preview_button": "Preview",
"download_button": "Download", "download_button": "Download",

View File

@ -13,6 +13,8 @@ enum class FileType(
GIF("gif", "image/gif", false, false, false), GIF("gif", "image/gif", false, false, false),
PNG("png", "image/png", false, true, false), PNG("png", "image/png", false, true, false),
MP4("mp4", "video/mp4", true, false, 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), MP3("mp3", "audio/mp3",false, false, true),
OPUS("opus", "audio/opus", false, false, true), OPUS("opus", "audio/opus", false, false, true),
AAC("aac", "audio/aac", false, false, true), AAC("aac", "audio/aac", false, false, true),
@ -34,6 +36,9 @@ enum class FileType(
"4f676753" to OPUS, "4f676753" to OPUS,
"fff15" to AAC, "fff15" to AAC,
"ffd8ff" to JPG, "ffd8ff" to JPG,
"47494638" to GIF,
"1a45dfa3" to MKV,
"52494646" to AVI,
) )
fun fromString(string: String?): FileType { fun fromString(string: String?): FileType {

View File

@ -40,12 +40,12 @@ fun createNewFilePath(
config: RootConfig, config: RootConfig,
hexHash: String, hexHash: String,
downloadSource: MediaDownloadSource, downloadSource: MediaDownloadSource,
mediaAuthor: String, mediaAuthor: String?,
creationTimestamp: Long? creationTimestamp: Long?
): String { ): String {
val pathFormat by config.downloader.pathFormat val pathFormat by config.downloader.pathFormat
val customPathFormat by config.downloader.customPathFormat 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 currentDateTime = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.ENGLISH).format(creationTimestamp ?: System.currentTimeMillis())
val finalPath = StringBuilder() val finalPath = StringBuilder()

View File

@ -1,25 +1,31 @@
package me.rhunk.snapenhance.common.data.download package me.rhunk.snapenhance.common.data.download
import me.rhunk.snapenhance.common.bridge.wrapper.LocaleWrapper
enum class MediaDownloadSource( enum class MediaDownloadSource(
val key: String, val key: String,
val displayName: String = key,
val pathName: String = key, val pathName: String = key,
val ignoreFilter: Boolean = false val ignoreFilter: Boolean = false
) { ) {
NONE("none", "None", ignoreFilter = true), NONE("none", ignoreFilter = true),
PENDING("pending", "Pending", ignoreFilter = true), PENDING("pending", ignoreFilter = true),
CHAT_MEDIA("chat_media", "Chat Media", "chat_media"), CHAT_MEDIA("chat_media", "chat_media"),
STORY("story", "Story", "story"), STORY("story", "story"),
PUBLIC_STORY("public_story", "Public Story", "public_story"), PUBLIC_STORY("public_story", "public_story"),
SPOTLIGHT("spotlight", "Spotlight", "spotlight"), SPOTLIGHT("spotlight", "spotlight"),
PROFILE_PICTURE("profile_picture", "Profile Picture", "profile_picture"), PROFILE_PICTURE("profile_picture", "profile_picture"),
STORY_LOGGER("story_logger", "Story Logger", "story_logger"); STORY_LOGGER("story_logger", "story_logger"),
MERGED("merged", "merged");
fun matches(source: String?): Boolean { fun matches(source: String?): Boolean {
if (source == null) return false if (source == null) return false
return source.contains(key, ignoreCase = true) return source.contains(key, ignoreCase = true)
} }
fun translate(translation: LocaleWrapper): String {
return translation["media_download_source.$key"]
}
companion object { companion object {
fun fromKey(key: String?): MediaDownloadSource { fun fromKey(key: String?): MediaDownloadSource {
if (key == null) return NONE if (key == null) return NONE

View File

@ -102,7 +102,7 @@ class MediaDownloader : MessagingRuleFeature("MediaDownloader", MessagingRuleTyp
metadata = DownloadMetadata( metadata = DownloadMetadata(
mediaIdentifier = generatedHash, mediaIdentifier = generatedHash,
mediaAuthor = mediaAuthor, mediaAuthor = mediaAuthor,
downloadSource = downloadSource.key, downloadSource = downloadSource.translate(context.translation),
iconUrl = iconUrl, iconUrl = iconUrl,
outputPath = outputPath outputPath = outputPath
), ),