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.RECORDER_MEDIA_SELECTED_VALUE
|
||||||
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
import app.myzel394.alibi.ui.SUPPORTS_SCOPED_STORAGE
|
||||||
import app.myzel394.alibi.ui.utils.PermissionHelper
|
import app.myzel394.alibi.ui.utils.PermissionHelper
|
||||||
|
import com.arthenica.ffmpegkit.FFprobeKit
|
||||||
|
import com.arthenica.ffmpegkit.FFprobeSession
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlin.reflect.KFunction3
|
import kotlin.reflect.KFunction4
|
||||||
|
|
||||||
abstract class BatchesFolder(
|
abstract class BatchesFolder(
|
||||||
open val context: Context,
|
open val context: Context,
|
||||||
@ -32,7 +34,7 @@ abstract class BatchesFolder(
|
|||||||
open val customFolder: DocumentFile? = null,
|
open val customFolder: DocumentFile? = null,
|
||||||
open val subfolderName: String = ".recordings",
|
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 ffmpegParameters: Array<String>
|
||||||
abstract val scopedMediaContentUri: Uri
|
abstract val scopedMediaContentUri: Uri
|
||||||
abstract val legacyMediaFolder: File
|
abstract val legacyMediaFolder: File
|
||||||
@ -245,11 +247,13 @@ abstract class BatchesFolder(
|
|||||||
|
|
||||||
abstract fun cleanup()
|
abstract fun cleanup()
|
||||||
|
|
||||||
open suspend fun concatenate(
|
suspend fun concatenate(
|
||||||
recordingStart: LocalDateTime,
|
recordingStart: LocalDateTime,
|
||||||
extension: String,
|
extension: String,
|
||||||
disableCache: Boolean = false,
|
disableCache: Boolean = false,
|
||||||
onNextParameterTry: (String) -> Unit = {},
|
onNextParameterTry: (String) -> Unit = {},
|
||||||
|
durationPerBatchInMilliseconds: Long = 0,
|
||||||
|
onProgress: (Float) -> Unit = {},
|
||||||
): String {
|
): String {
|
||||||
if (!disableCache && checkIfOutputAlreadyExists(recordingStart, extension)) {
|
if (!disableCache && checkIfOutputAlreadyExists(recordingStart, extension)) {
|
||||||
return getOutputFileForFFmpeg(
|
return getOutputFileForFFmpeg(
|
||||||
@ -264,6 +268,20 @@ abstract class BatchesFolder(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
val filePaths = getBatchesForFFmpeg()
|
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(
|
val outputFile = getOutputFileForFFmpeg(
|
||||||
date = recordingStart,
|
date = recordingStart,
|
||||||
extension = extension,
|
extension = extension,
|
||||||
@ -272,8 +290,12 @@ abstract class BatchesFolder(
|
|||||||
concatenationFunction(
|
concatenationFunction(
|
||||||
filePaths,
|
filePaths,
|
||||||
outputFile,
|
outputFile,
|
||||||
parameter,
|
parameter
|
||||||
).await()
|
) { time ->
|
||||||
|
if (fullTime != null) {
|
||||||
|
onProgress(time / fullTime!!)
|
||||||
|
}
|
||||||
|
}.await()
|
||||||
return outputFile
|
return outputFile
|
||||||
} catch (e: MediaConverter.FFmpegException) {
|
} catch (e: MediaConverter.FFmpegException) {
|
||||||
continue
|
continue
|
||||||
|
@ -10,6 +10,71 @@ import com.arthenica.ffmpegkit.ReturnCode
|
|||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
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 {
|
class MediaConverter {
|
||||||
companion object {
|
companion object {
|
||||||
@ -17,6 +82,7 @@ class MediaConverter {
|
|||||||
inputFiles: Iterable<String>,
|
inputFiles: Iterable<String>,
|
||||||
outputFile: String,
|
outputFile: String,
|
||||||
extraCommand: String = "",
|
extraCommand: String = "",
|
||||||
|
onProgress: (Int) -> Unit = { },
|
||||||
): CompletableDeferred<Unit> {
|
): CompletableDeferred<Unit> {
|
||||||
val completer = CompletableDeferred<Unit>()
|
val completer = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
@ -63,6 +129,7 @@ class MediaConverter {
|
|||||||
inputFiles: Iterable<String>,
|
inputFiles: Iterable<String>,
|
||||||
outputFile: String,
|
outputFile: String,
|
||||||
extraCommand: String = "",
|
extraCommand: String = "",
|
||||||
|
onProgress: (Int) -> Unit = { },
|
||||||
): CompletableDeferred<Unit> {
|
): CompletableDeferred<Unit> {
|
||||||
val completer = CompletableDeferred<Unit>()
|
val completer = CompletableDeferred<Unit>()
|
||||||
|
|
||||||
@ -75,32 +142,40 @@ class MediaConverter {
|
|||||||
" -i ${listFile.absolutePath}" +
|
" -i ${listFile.absolutePath}" +
|
||||||
extraCommand +
|
extraCommand +
|
||||||
" -strict normal" +
|
" -strict normal" +
|
||||||
|
// TODO: Check if those params work
|
||||||
|
" -nostats" +
|
||||||
|
" -loglevel error" +
|
||||||
" -y" +
|
" -y" +
|
||||||
" $outputFile"
|
" $outputFile"
|
||||||
|
|
||||||
FFmpegKit.executeAsync(
|
FFmpegKit.executeAsync(
|
||||||
command
|
command,
|
||||||
) { session ->
|
{ session ->
|
||||||
runCatching {
|
runCatching {
|
||||||
listFile.delete()
|
listFile.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ReturnCode.isSuccess(session!!.returnCode)) {
|
if (ReturnCode.isSuccess(session!!.returnCode)) {
|
||||||
Log.d(
|
completer.complete(Unit)
|
||||||
"Video Concatenation",
|
} else {
|
||||||
String.format(
|
Log.d(
|
||||||
"Command failed with state %s and rc %s.%s",
|
"Video Concatenation",
|
||||||
session.state,
|
String.format(
|
||||||
session.returnCode,
|
"Command failed with state %s and rc %s.%s",
|
||||||
session.failStackTrace,
|
session.state,
|
||||||
|
session.returnCode,
|
||||||
|
session.failStackTrace,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
completer.completeExceptionally(FFmpegException("Failed to concatenate videos"))
|
completer.completeExceptionally(FFmpegException("Failed to concatenate videos"))
|
||||||
} else {
|
}
|
||||||
completer.complete(Unit)
|
},
|
||||||
|
{},
|
||||||
|
{ statistics ->
|
||||||
|
onProgress(statistics.time)
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
|
||||||
return completer
|
return completer
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,9 @@ import androidx.compose.ui.unit.dp
|
|||||||
import app.myzel394.alibi.R
|
import app.myzel394.alibi.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RecorderProcessingDialog() {
|
fun RecorderProcessingDialog(
|
||||||
|
progress: Float?,
|
||||||
|
) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { },
|
onDismissRequest = { },
|
||||||
icon = {
|
icon = {
|
||||||
@ -39,7 +41,10 @@ fun RecorderProcessingDialog() {
|
|||||||
stringResource(R.string.ui_recorder_action_save_processing_dialog_description),
|
stringResource(R.string.ui_recorder_action_save_processing_dialog_description),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
LinearProgressIndicator()
|
if (progress != null)
|
||||||
|
LinearProgressIndicator(progress = progress)
|
||||||
|
else
|
||||||
|
LinearProgressIndicator()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {}
|
confirmButton = {}
|
||||||
|
@ -9,6 +9,8 @@ import androidx.compose.material3.SnackbarResult
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableDoubleStateOf
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
@ -32,6 +34,8 @@ import app.myzel394.alibi.ui.models.VideoRecorderModel
|
|||||||
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
import app.myzel394.alibi.ui.utils.rememberFileSaverDialog
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.concurrent.thread
|
||||||
|
|
||||||
typealias RecorderModel = BaseRecorderModel<
|
typealias RecorderModel = BaseRecorderModel<
|
||||||
RecordingInformation,
|
RecordingInformation,
|
||||||
@ -54,6 +58,8 @@ fun RecorderEventsHandler(
|
|||||||
var showRecorderError by remember { mutableStateOf(false) }
|
var showRecorderError by remember { mutableStateOf(false) }
|
||||||
var showBatchesInaccessibleError by remember { mutableStateOf(false) }
|
var showBatchesInaccessibleError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var processingProgress by remember { mutableFloatStateOf(0.0f) }
|
||||||
|
|
||||||
val saveAudioFile = rememberFileSaverDialog(settings.audioRecorderSettings.getMimeType()) {
|
val saveAudioFile = rememberFileSaverDialog(settings.audioRecorderSettings.getMimeType()) {
|
||||||
if (settings.deleteRecordingsImmediately) {
|
if (settings.deleteRecordingsImmediately) {
|
||||||
runCatching {
|
runCatching {
|
||||||
@ -135,81 +141,89 @@ fun RecorderEventsHandler(
|
|||||||
// Give the user some time to see the processing dialog
|
// Give the user some time to see the processing dialog
|
||||||
delay(100)
|
delay(100)
|
||||||
|
|
||||||
try {
|
thread {
|
||||||
val recording =
|
runBlocking {
|
||||||
// When new recording created
|
try {
|
||||||
recorder.recorderService?.getRecordingInformation()
|
val recording =
|
||||||
// When recording is loaded from lastRecording
|
// When new recording created
|
||||||
?: settings.lastRecording
|
recorder.recorderService?.getRecordingInformation()
|
||||||
?: throw Exception("No recording information available")
|
// When recording is loaded from lastRecording
|
||||||
val batchesFolder = when (recorder.javaClass) {
|
?: settings.lastRecording
|
||||||
AudioRecorderModel::class.java -> AudioBatchesFolder.importFromFolder(
|
?: throw Exception("No recording information available")
|
||||||
recording.folderPath,
|
val batchesFolder = when (recorder.javaClass) {
|
||||||
context
|
AudioRecorderModel::class.java -> AudioBatchesFolder.importFromFolder(
|
||||||
)
|
recording.folderPath,
|
||||||
|
context
|
||||||
|
)
|
||||||
|
|
||||||
VideoRecorderModel::class.java -> VideoBatchesFolder.importFromFolder(
|
VideoRecorderModel::class.java -> VideoBatchesFolder.importFromFolder(
|
||||||
recording.folderPath,
|
recording.folderPath,
|
||||||
context
|
context
|
||||||
)
|
)
|
||||||
|
|
||||||
else -> throw Exception("Unknown recorder type")
|
else -> throw Exception("Unknown recorder type")
|
||||||
}
|
}
|
||||||
|
|
||||||
batchesFolder.concatenate(
|
batchesFolder.concatenate(
|
||||||
recording.recordingStart,
|
recording.recordingStart,
|
||||||
recording.fileExtension,
|
recording.fileExtension,
|
||||||
)
|
durationPerBatchInMilliseconds = settings.intervalDuration,
|
||||||
|
onProgress = { percentage ->
|
||||||
|
processingProgress = percentage
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Save file
|
// Save file
|
||||||
val name = batchesFolder.getName(
|
val name = batchesFolder.getName(
|
||||||
recording.recordingStart,
|
recording.recordingStart,
|
||||||
recording.fileExtension,
|
recording.fileExtension,
|
||||||
)
|
)
|
||||||
|
|
||||||
when (batchesFolder.type) {
|
when (batchesFolder.type) {
|
||||||
BatchesFolder.BatchType.INTERNAL -> {
|
BatchesFolder.BatchType.INTERNAL -> {
|
||||||
when (batchesFolder) {
|
when (batchesFolder) {
|
||||||
is AudioBatchesFolder -> {
|
is AudioBatchesFolder -> {
|
||||||
saveAudioFile(
|
saveAudioFile(
|
||||||
batchesFolder.asInternalGetOutputFile(
|
batchesFolder.asInternalGetOutputFile(
|
||||||
recording.recordingStart,
|
recording.recordingStart,
|
||||||
recording.fileExtension,
|
recording.fileExtension,
|
||||||
), name
|
), name
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is VideoBatchesFolder -> {
|
||||||
|
saveVideoFile(
|
||||||
|
batchesFolder.asInternalGetOutputFile(
|
||||||
|
recording.recordingStart,
|
||||||
|
recording.fileExtension,
|
||||||
|
), name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is VideoBatchesFolder -> {
|
BatchesFolder.BatchType.CUSTOM -> {
|
||||||
saveVideoFile(
|
showSnackbar(batchesFolder.customFolder!!.uri)
|
||||||
batchesFolder.asInternalGetOutputFile(
|
|
||||||
recording.recordingStart,
|
if (settings.deleteRecordingsImmediately) {
|
||||||
recording.fileExtension,
|
batchesFolder.deleteRecordings()
|
||||||
), name
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
BatchesFolder.BatchType.MEDIA -> {
|
||||||
|
showSnackbar()
|
||||||
|
|
||||||
|
if (settings.deleteRecordingsImmediately) {
|
||||||
|
batchesFolder.deleteRecordings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (error: Exception) {
|
||||||
|
Log.getStackTraceString(error)
|
||||||
BatchesFolder.BatchType.CUSTOM -> {
|
} finally {
|
||||||
showSnackbar(batchesFolder.customFolder!!.uri)
|
isProcessing = false
|
||||||
|
|
||||||
if (settings.deleteRecordingsImmediately) {
|
|
||||||
batchesFolder.deleteRecordings()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BatchesFolder.BatchType.MEDIA -> {
|
|
||||||
showSnackbar()
|
|
||||||
|
|
||||||
if (settings.deleteRecordingsImmediately) {
|
|
||||||
batchesFolder.deleteRecordings()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: Exception) {
|
|
||||||
Log.getStackTraceString(error)
|
|
||||||
} finally {
|
|
||||||
isProcessing = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,8 +320,11 @@ fun RecorderEventsHandler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isProcessing)
|
if (isProcessing)
|
||||||
RecorderProcessingDialog()
|
RecorderProcessingDialog(
|
||||||
|
progress = processingProgress,
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: Add thread for concatenation
|
||||||
if (showRecorderError)
|
if (showRecorderError)
|
||||||
RecorderErrorDialog(
|
RecorderErrorDialog(
|
||||||
onClose = {
|
onClose = {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user