feat: Add progress bar support for video

This commit is contained in:
Myzel394 2024-01-04 00:13:49 +01:00
parent 3f1e00ac82
commit 386d3cb733
No known key found for this signature in database
GPG Key ID: 79CC92F37B3E1A2B
4 changed files with 209 additions and 90 deletions

View File

@ -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<Iterable<String>, String, String, CompletableDeferred<Unit>>
abstract val concatenationFunction: KFunction4<Iterable<String>, String, String, (Int) -> Unit, CompletableDeferred<Unit>>
abstract val ffmpegParameters: Array<String>
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

View File

@ -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<String>,
private val outputFile: String,
private val extraCommand: String
) : Thread() {
abstract fun concatenate(): CompletableDeferred<Unit>
class FFmpegException(message: String) : Exception(message)
}
data class AudioConcatenator(
private val inputFiles: Iterable<String>,
private val outputFile: String,
private val extraCommand: String
) : Concatenator(
inputFiles,
outputFile,
extraCommand
) {
override fun concatenate(): CompletableDeferred<Unit> {
val completer = CompletableDeferred<Unit>()
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<String>,
outputFile: String,
extraCommand: String = "",
onProgress: (Int) -> Unit = { },
): CompletableDeferred<Unit> {
val completer = CompletableDeferred<Unit>()
@ -63,6 +129,7 @@ class MediaConverter {
inputFiles: Iterable<String>,
outputFile: String,
extraCommand: String = "",
onProgress: (Int) -> Unit = { },
): CompletableDeferred<Unit> {
val completer = CompletableDeferred<Unit>()
@ -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
}

View File

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

View File

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