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)
}
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
))

View File

@ -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)

View File

@ -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
}

View File

@ -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 = {

View File

@ -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()
}
}

View File

@ -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")

View File

@ -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",

View File

@ -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 {

View File

@ -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()

View File

@ -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

View File

@ -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
),