diff --git a/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt index c250ad9..9b80a25 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/BatchesFolder.kt @@ -23,8 +23,10 @@ import app.myzel394.alibi.ui.RECORDER_INTERNAL_SELECTED_VALUE import app.myzel394.alibi.ui.RECORDER_MEDIA_SELECTED_VALUE import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE import app.myzel394.alibi.ui.utils.PermissionHelper +import com.arthenica.ffmpegkit.FFprobeKit +import com.arthenica.ffmpegkit.FFprobeSession import kotlinx.coroutines.CompletableDeferred -import kotlin.reflect.KFunction3 +import kotlin.reflect.KFunction4 abstract class BatchesFolder( open val context: Context, @@ -32,7 +34,7 @@ abstract class BatchesFolder( open val customFolder: DocumentFile? = null, open val subfolderName: String = ".recordings", ) { - abstract val concatenationFunction: KFunction3, String, String, CompletableDeferred> + abstract val concatenationFunction: KFunction4, String, String, (Int) -> Unit, CompletableDeferred> abstract val ffmpegParameters: Array abstract val scopedMediaContentUri: Uri abstract val legacyMediaFolder: File @@ -245,11 +247,13 @@ abstract class BatchesFolder( abstract fun cleanup() - open suspend fun concatenate( + suspend fun concatenate( recordingStart: LocalDateTime, extension: String, disableCache: Boolean = false, onNextParameterTry: (String) -> Unit = {}, + durationPerBatchInMilliseconds: Long = 0, + onProgress: (Float) -> Unit = {}, ): String { if (!disableCache && checkIfOutputAlreadyExists(recordingStart, extension)) { return getOutputFileForFFmpeg( @@ -264,6 +268,20 @@ abstract class BatchesFolder( try { val filePaths = getBatchesForFFmpeg() + + // Casting here to float so it doesn't need to redo it on every progress update + var fullTime: Float? = null + + runCatching { + // `fullTime` is not accurate as the last batch might be shorter, + // but it's good enough for the progress bar + val lastBatchTime = (FFprobeKit.execute( + "-i ${filePaths.last()} -show_entries format=duration -v quiet -of csv=\"p=0\"", + ).output.toFloat() * 1000).toLong() + fullTime = + ((durationPerBatchInMilliseconds * (filePaths.size - 1)) + lastBatchTime).toFloat() + } + val outputFile = getOutputFileForFFmpeg( date = recordingStart, extension = extension, @@ -272,8 +290,12 @@ abstract class BatchesFolder( concatenationFunction( filePaths, outputFile, - parameter, - ).await() + parameter + ) { time -> + if (fullTime != null) { + onProgress(time / fullTime!!) + } + }.await() return outputFile } catch (e: MediaConverter.FFmpegException) { continue diff --git a/app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt b/app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt index ce1f77e..715bae6 100644 --- a/app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt +++ b/app/src/main/java/app/myzel394/alibi/helpers/MediaConverter.kt @@ -10,6 +10,71 @@ import com.arthenica.ffmpegkit.ReturnCode import kotlinx.coroutines.CompletableDeferred import java.io.File import java.util.UUID +import kotlin.math.log + +// Abstract class for concatenating audio and video files +// The concatenator runs in its own thread to avoid unresponsiveness. +// You may be wondering why we simply not iterate over the FFMPEG_PARAMETERS +// in this thread and then call each FFmpeg initiation just right after it? +// The answer: It's easier; We don't have to deal with the `getBatchesForFFmpeg` function, because +// the batches are only usable once and we if iterate in this thread over the FFMPEG_PARAMETERS +// we would need to refetch the batches here, which is more messy. +// This is okay, because in 99% of the time the first or second parameter will work, +// and so there is no real performance loss. +abstract class Concatenator( + private val inputFiles: Iterable, + private val outputFile: String, + private val extraCommand: String +) : Thread() { + abstract fun concatenate(): CompletableDeferred + + class FFmpegException(message: String) : Exception(message) +} + +data class AudioConcatenator( + private val inputFiles: Iterable, + private val outputFile: String, + private val extraCommand: String +) : Concatenator( + inputFiles, + outputFile, + extraCommand +) { + override fun concatenate(): CompletableDeferred { + val completer = CompletableDeferred() + + val filePathsConcatenated = inputFiles.joinToString("|") + val command = + "-protocol_whitelist saf,concat,content,file,subfile" + + " -i 'concat:$filePathsConcatenated'" + + " -y" + + extraCommand + + " $outputFile" + + FFmpegKit.executeAsync( + command + ) { session -> + if (!ReturnCode.isSuccess(session!!.returnCode)) { + Log.d( + "Audio Concatenation", + String.format( + "Command failed with state %s and rc %s.%s", + session.state, + session.returnCode, + session.failStackTrace, + ) + ) + + completer.completeExceptionally(Exception("Failed to concatenate audios")) + } else { + completer.complete(Unit) + } + } + + return completer + } +} + class MediaConverter { companion object { @@ -17,6 +82,7 @@ class MediaConverter { inputFiles: Iterable, outputFile: String, extraCommand: String = "", + onProgress: (Int) -> Unit = { }, ): CompletableDeferred { val completer = CompletableDeferred() @@ -63,6 +129,7 @@ class MediaConverter { inputFiles: Iterable, outputFile: String, extraCommand: String = "", + onProgress: (Int) -> Unit = { }, ): CompletableDeferred { val completer = CompletableDeferred() @@ -75,32 +142,40 @@ class MediaConverter { " -i ${listFile.absolutePath}" + extraCommand + " -strict normal" + + // TODO: Check if those params work + " -nostats" + + " -loglevel error" + " -y" + " $outputFile" FFmpegKit.executeAsync( - command - ) { session -> - runCatching { - listFile.delete() - } + command, + { session -> + runCatching { + listFile.delete() + } - if (!ReturnCode.isSuccess(session!!.returnCode)) { - Log.d( - "Video Concatenation", - String.format( - "Command failed with state %s and rc %s.%s", - session.state, - session.returnCode, - session.failStackTrace, + if (ReturnCode.isSuccess(session!!.returnCode)) { + completer.complete(Unit) + } else { + Log.d( + "Video Concatenation", + String.format( + "Command failed with state %s and rc %s.%s", + session.state, + session.returnCode, + session.failStackTrace, + ) ) - ) - completer.completeExceptionally(FFmpegException("Failed to concatenate videos")) - } else { - completer.complete(Unit) + completer.completeExceptionally(FFmpegException("Failed to concatenate videos")) + } + }, + {}, + { statistics -> + onProgress(statistics.time) } - } + ) return completer } diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderProcessingDialog.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderProcessingDialog.kt index 763bc32..7c03ac6 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderProcessingDialog.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/atoms/RecorderProcessingDialog.kt @@ -17,7 +17,9 @@ import androidx.compose.ui.unit.dp import app.myzel394.alibi.R @Composable -fun RecorderProcessingDialog() { +fun RecorderProcessingDialog( + progress: Float?, +) { AlertDialog( onDismissRequest = { }, icon = { @@ -39,7 +41,10 @@ fun RecorderProcessingDialog() { stringResource(R.string.ui_recorder_action_save_processing_dialog_description), ) Spacer(modifier = Modifier.height(32.dp)) - LinearProgressIndicator() + if (progress != null) + LinearProgressIndicator(progress = progress) + else + LinearProgressIndicator() } }, confirmButton = {} diff --git a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt index f7fce5e..727f1fa 100644 --- a/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt +++ b/app/src/main/java/app/myzel394/alibi/ui/components/RecorderScreen/organisms/RecorderEventsHandler.kt @@ -9,6 +9,8 @@ import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -32,6 +34,8 @@ import app.myzel394.alibi.ui.models.VideoRecorderModel import app.myzel394.alibi.ui.utils.rememberFileSaverDialog import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.concurrent.thread typealias RecorderModel = BaseRecorderModel< RecordingInformation, @@ -54,6 +58,8 @@ fun RecorderEventsHandler( var showRecorderError by remember { mutableStateOf(false) } var showBatchesInaccessibleError by remember { mutableStateOf(false) } + var processingProgress by remember { mutableFloatStateOf(0.0f) } + val saveAudioFile = rememberFileSaverDialog(settings.audioRecorderSettings.getMimeType()) { if (settings.deleteRecordingsImmediately) { runCatching { @@ -135,81 +141,89 @@ fun RecorderEventsHandler( // Give the user some time to see the processing dialog delay(100) - try { - val recording = - // When new recording created - recorder.recorderService?.getRecordingInformation() - // When recording is loaded from lastRecording - ?: settings.lastRecording - ?: throw Exception("No recording information available") - val batchesFolder = when (recorder.javaClass) { - AudioRecorderModel::class.java -> AudioBatchesFolder.importFromFolder( - recording.folderPath, - context - ) + thread { + runBlocking { + try { + val recording = + // When new recording created + recorder.recorderService?.getRecordingInformation() + // When recording is loaded from lastRecording + ?: settings.lastRecording + ?: throw Exception("No recording information available") + val batchesFolder = when (recorder.javaClass) { + AudioRecorderModel::class.java -> AudioBatchesFolder.importFromFolder( + recording.folderPath, + context + ) - VideoRecorderModel::class.java -> VideoBatchesFolder.importFromFolder( - recording.folderPath, - context - ) + VideoRecorderModel::class.java -> VideoBatchesFolder.importFromFolder( + recording.folderPath, + context + ) - else -> throw Exception("Unknown recorder type") - } + else -> throw Exception("Unknown recorder type") + } - batchesFolder.concatenate( - recording.recordingStart, - recording.fileExtension, - ) + batchesFolder.concatenate( + recording.recordingStart, + recording.fileExtension, + durationPerBatchInMilliseconds = settings.intervalDuration, + onProgress = { percentage -> + processingProgress = percentage + } + ) - // Save file - val name = batchesFolder.getName( - recording.recordingStart, - recording.fileExtension, - ) + // Save file + val name = batchesFolder.getName( + recording.recordingStart, + recording.fileExtension, + ) - when (batchesFolder.type) { - BatchesFolder.BatchType.INTERNAL -> { - when (batchesFolder) { - is AudioBatchesFolder -> { - saveAudioFile( - batchesFolder.asInternalGetOutputFile( - recording.recordingStart, - recording.fileExtension, - ), name - ) + when (batchesFolder.type) { + BatchesFolder.BatchType.INTERNAL -> { + when (batchesFolder) { + is AudioBatchesFolder -> { + saveAudioFile( + batchesFolder.asInternalGetOutputFile( + recording.recordingStart, + recording.fileExtension, + ), name + ) + } + + is VideoBatchesFolder -> { + saveVideoFile( + batchesFolder.asInternalGetOutputFile( + recording.recordingStart, + recording.fileExtension, + ), name + ) + } + } } - is VideoBatchesFolder -> { - saveVideoFile( - batchesFolder.asInternalGetOutputFile( - recording.recordingStart, - recording.fileExtension, - ), name - ) + BatchesFolder.BatchType.CUSTOM -> { + showSnackbar(batchesFolder.customFolder!!.uri) + + if (settings.deleteRecordingsImmediately) { + batchesFolder.deleteRecordings() + } + } + + BatchesFolder.BatchType.MEDIA -> { + showSnackbar() + + if (settings.deleteRecordingsImmediately) { + batchesFolder.deleteRecordings() + } } } - } - - BatchesFolder.BatchType.CUSTOM -> { - showSnackbar(batchesFolder.customFolder!!.uri) - - if (settings.deleteRecordingsImmediately) { - batchesFolder.deleteRecordings() - } - } - - BatchesFolder.BatchType.MEDIA -> { - showSnackbar() - - if (settings.deleteRecordingsImmediately) { - batchesFolder.deleteRecordings() - } + } catch (error: Exception) { + Log.getStackTraceString(error) + } finally { + isProcessing = false } } - } catch (error: Exception) { - Log.getStackTraceString(error) - } finally { - isProcessing = false } } @@ -306,8 +320,11 @@ fun RecorderEventsHandler( } if (isProcessing) - RecorderProcessingDialog() + RecorderProcessingDialog( + progress = processingProgress, + ) + // TODO: Add thread for concatenation if (showRecorderError) RecorderErrorDialog( onClose = {