mirror of
https://github.com/Myzel394/Alibi.git
synced 2025-06-18 23:05:26 +02:00
feat: Add progress bar support for video
This commit is contained in:
parent
3f1e00ac82
commit
386d3cb733
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 = {}
|
||||
|
@ -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 = {
|
||||
|
Loading…
x
Reference in New Issue
Block a user