diff --git a/app/src/androidTest/java/org/libremediaconverter/bench/RealMediaBenchmark.kt b/app/src/androidTest/java/org/libremediaconverter/bench/RealMediaBenchmark.kt index 887e811..3787388 100644 --- a/app/src/androidTest/java/org/libremediaconverter/bench/RealMediaBenchmark.kt +++ b/app/src/androidTest/java/org/libremediaconverter/bench/RealMediaBenchmark.kt @@ -4,10 +4,13 @@ import android.media.MediaExtractor import android.media.MediaFormat import android.net.Uri import android.util.Log -import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith import org.libremediaconverter.codec.AndroidDeviceCodecs import org.libremediaconverter.convert.Media3Engine import org.libremediaconverter.convert.MediaProbe @@ -17,10 +20,6 @@ import org.libremediaconverter.model.ConversionRouter import org.libremediaconverter.model.OutputFormat import org.libremediaconverter.model.QualityTier import org.libremediaconverter.model.VideoCodec -import kotlinx.coroutines.runBlocking -import org.junit.Assume.assumeTrue -import org.junit.Test -import org.junit.runner.RunWith import java.io.File /** @@ -45,6 +44,7 @@ import java.io.File class RealMediaBenchmark { private val context = InstrumentationRegistry.getInstrumentation().targetContext + /** * Internal storage, not the external files dir. * @@ -55,8 +55,7 @@ class RealMediaBenchmark { */ private val samples: File get() = context.filesDir - private fun sample(name: String): File? = - File(samples, name).takeIf { it.exists() && it.length() > 0 } + private fun sample(name: String): File? = File(samples, name).takeIf { it.exists() && it.length() > 0 } private fun durationMs(file: File): Long { val extractor = MediaExtractor() diff --git a/app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt b/app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt index 827a212..ee6a988 100644 --- a/app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/convert/RemuxTest.kt @@ -20,7 +20,6 @@ import org.junit.Test import org.junit.runner.RunWith import org.libremediaconverter.model.AudioCodec import org.libremediaconverter.model.Container -import org.libremediaconverter.model.ConversionRequest import org.libremediaconverter.model.Engine import org.libremediaconverter.model.InputKind import org.libremediaconverter.model.OutputSpec @@ -84,14 +83,13 @@ class RemuxTest { WorkManager.getInstance(context).cancelAllWork() } - private fun asset(name: String): File = - File(context.cacheDir, name).also { file -> - file.delete() - staged += file - InstrumentationRegistry.getInstrumentation().context.assets - .open(name) - .use { asset -> file.outputStream().use { asset.copyTo(it) } } - } + private fun asset(name: String): File = File(context.cacheDir, name).also { file -> + file.delete() + staged += file + InstrumentationRegistry.getInstrumentation().context.assets + .open(name) + .use { asset -> file.outputStream().use { asset.copyTo(it) } } + } // --- probing ------------------------------------------------------------ @@ -255,8 +253,7 @@ class RemuxTest { } } - private fun videoMimeOf(file: File): String? = - trackMimesOf(file).firstOrNull { it.startsWith("video/") } + private fun videoMimeOf(file: File): String? = trackMimesOf(file).firstOrNull { it.startsWith("video/") } private fun durationMsOf(file: File): Long { val extractor = MediaExtractor() diff --git a/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt b/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt index 3171d9a..e8393eb 100644 --- a/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt +++ b/app/src/androidTest/java/org/libremediaconverter/fallback/FakeFailures.kt @@ -7,7 +7,6 @@ import org.libremediaconverter.convert.HardwareTranscoder import org.libremediaconverter.convert.OutputPublisher import org.libremediaconverter.convert.SoftwareTranscoder import org.libremediaconverter.model.ConversionRequest -import org.libremediaconverter.model.OutputFormat import java.io.File /** diff --git a/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt b/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt index 618308a..4601c91 100644 --- a/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/fallback/ForcedFailureTest.kt @@ -6,13 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.work.WorkInfo import androidx.work.WorkManager -import org.libremediaconverter.convert.ConversionDependencies -import org.libremediaconverter.model.DeviceCodecs -import org.libremediaconverter.model.Engine -import org.libremediaconverter.model.OutputFormat -import org.libremediaconverter.model.QualityTier -import org.libremediaconverter.work.ConcatWorker -import org.libremediaconverter.work.ConversionWorker import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -22,6 +15,13 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.libremediaconverter.convert.ConversionDependencies +import org.libremediaconverter.model.DeviceCodecs +import org.libremediaconverter.model.Engine +import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.QualityTier +import org.libremediaconverter.work.ConcatWorker +import org.libremediaconverter.work.ConversionWorker import java.io.File /** @@ -61,14 +61,13 @@ class ForcedFailureTest { } } - private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) = - ConversionWorker.request( - inputUri = Uri.fromFile(input), - displayName = SAMPLE, - sizeBytes = input.length(), - spec = format.spec, - quality = QualityTier.FAST, - ) + private fun convertRequest(format: OutputFormat = OutputFormat.MP4_H265) = ConversionWorker.request( + inputUri = Uri.fromFile(input), + displayName = SAMPLE, + sizeBytes = input.length(), + spec = format.spec, + quality = QualityTier.FAST, + ) // --- the dynamic fallback, forced rather than provoked ------------------- @@ -179,11 +178,9 @@ class ForcedFailureTest { assertEquals(WorkInfo.State.FAILED, terminal?.state) } - private fun assertTrue(message: String, condition: Boolean) = - org.junit.Assert.assertTrue(message, condition) + private fun assertTrue(message: String, condition: Boolean) = org.junit.Assert.assertTrue(message, condition) - private fun assertTrue(condition: Boolean, message: String) = - org.junit.Assert.assertTrue(message, condition) + private fun assertTrue(condition: Boolean, message: String) = org.junit.Assert.assertTrue(message, condition) private companion object { const val SAMPLE = "sample_h264.mp4" diff --git a/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt b/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt index 97c64f3..b05f5b9 100644 --- a/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/fallback/HardwareFallbackTest.kt @@ -6,9 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.work.WorkInfo import androidx.work.WorkManager -import org.libremediaconverter.model.OutputFormat -import org.libremediaconverter.model.QualityTier -import org.libremediaconverter.work.ConversionWorker import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -18,6 +15,9 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.QualityTier +import org.libremediaconverter.work.ConversionWorker import java.io.File /** diff --git a/app/src/androidTest/java/org/libremediaconverter/fallback/UnopenableUriTest.kt b/app/src/androidTest/java/org/libremediaconverter/fallback/UnopenableUriTest.kt index 4b1084d..7009e62 100644 --- a/app/src/androidTest/java/org/libremediaconverter/fallback/UnopenableUriTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/fallback/UnopenableUriTest.kt @@ -8,10 +8,6 @@ import androidx.work.Data import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager -import org.libremediaconverter.convert.OutputPublisher -import org.libremediaconverter.ffmpeg.ConcatEngine -import org.libremediaconverter.work.ConcatWorker -import org.libremediaconverter.work.ConversionWorker import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -21,6 +17,10 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.libremediaconverter.convert.OutputPublisher +import org.libremediaconverter.ffmpeg.ConcatEngine +import org.libremediaconverter.work.ConcatWorker +import org.libremediaconverter.work.ConversionWorker import java.io.File /** diff --git a/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt b/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt index 40264a2..79ad243 100644 --- a/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/ffmpeg/FFmpegEngineTest.kt @@ -4,9 +4,6 @@ import android.media.MediaExtractor import android.media.MediaFormat import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import org.libremediaconverter.model.ConversionRequest -import org.libremediaconverter.model.OutputFormat -import org.libremediaconverter.model.QualityTier import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals @@ -14,6 +11,9 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.libremediaconverter.model.ConversionRequest +import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.QualityTier import java.io.File /** @@ -46,8 +46,10 @@ class FFmpegEngineTest { outputs.forEach { it.delete() } } - private fun outputFor(name: String) = - File(context.cacheDir, name).also { it.delete(); outputs += it } + private fun outputFor(name: String) = File(context.cacheDir, name).also { + it.delete() + outputs += it + } private fun convert(format: OutputFormat, quality: QualityTier = QualityTier.BEST): File { val out = outputFor("out_${format.name.lowercase()}.${format.extension}") diff --git a/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt b/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt index b9c703c..745b8aa 100644 --- a/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/join/ConcatEngineTest.kt @@ -5,9 +5,6 @@ import android.media.MediaFormat import android.net.Uri import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import org.libremediaconverter.convert.MediaProbe -import org.libremediaconverter.ffmpeg.ConcatEngine -import org.libremediaconverter.model.ConcatStrategy import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals @@ -15,6 +12,9 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.libremediaconverter.convert.MediaProbe +import org.libremediaconverter.ffmpeg.ConcatEngine +import org.libremediaconverter.model.ConcatStrategy import java.io.File /** @@ -57,8 +57,10 @@ class ConcatEngineTest { return out } - private fun output(name: String) = - File(context.cacheDir, name).also { it.delete(); staged += it } + private fun output(name: String) = File(context.cacheDir, name).also { + it.delete() + staged += it + } private fun durationMs(file: File): Long { val extractor = MediaExtractor() diff --git a/app/src/androidTest/java/org/libremediaconverter/join/ConcatWorkerTest.kt b/app/src/androidTest/java/org/libremediaconverter/join/ConcatWorkerTest.kt index 84bfdba..a4e2b6e 100644 --- a/app/src/androidTest/java/org/libremediaconverter/join/ConcatWorkerTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/join/ConcatWorkerTest.kt @@ -6,8 +6,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.work.WorkInfo import androidx.work.WorkManager -import org.libremediaconverter.model.ConcatStrategy -import org.libremediaconverter.work.ConcatWorker import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -17,6 +15,8 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.libremediaconverter.model.ConcatStrategy +import org.libremediaconverter.work.ConcatWorker import java.io.File /** diff --git a/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt b/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt index 9b4d5dd..b8b5b2a 100644 --- a/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt +++ b/app/src/androidTest/java/org/libremediaconverter/work/ConversionWorkerTest.kt @@ -8,14 +8,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.work.WorkInfo import androidx.work.WorkManager -import org.libremediaconverter.codec.AndroidDeviceCodecs -import org.libremediaconverter.model.Engine -import org.libremediaconverter.model.AudioCodec -import org.libremediaconverter.model.Container -import org.libremediaconverter.model.OutputFormat -import org.libremediaconverter.model.OutputSpec -import org.libremediaconverter.model.VideoCodec -import org.libremediaconverter.model.QualityTier import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -25,6 +17,14 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.libremediaconverter.codec.AndroidDeviceCodecs +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.Container +import org.libremediaconverter.model.Engine +import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.OutputSpec +import org.libremediaconverter.model.QualityTier +import org.libremediaconverter.model.VideoCodec import java.io.File /** @@ -170,8 +170,11 @@ class ConversionWorkerTest { terminal?.state, ) assertEquals( - if (hasHardwareHevc) "hardware HEVC present, expected the Media3 path" - else "no hardware HEVC encoder, expected the FFmpeg path", + if (hasHardwareHevc) { + "hardware HEVC present, expected the Media3 path" + } else { + "no hardware HEVC encoder, expected the FFmpeg path" + }, if (hasHardwareHevc) Engine.MEDIA3.name else Engine.FFMPEG.name, terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED), ) diff --git a/app/src/main/java/org/libremediaconverter/MainActivity.kt b/app/src/main/java/org/libremediaconverter/MainActivity.kt index 14139ae..6fd219f 100644 --- a/app/src/main/java/org/libremediaconverter/MainActivity.kt +++ b/app/src/main/java/org/libremediaconverter/MainActivity.kt @@ -18,8 +18,8 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier diff --git a/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt b/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt index de630bb..85475a3 100644 --- a/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt +++ b/app/src/main/java/org/libremediaconverter/codec/AndroidDeviceCodecs.kt @@ -1,6 +1,5 @@ package org.libremediaconverter.codec -import android.media.MediaCodecInfo import android.media.MediaCodecList import android.media.MediaFormat import android.util.Log @@ -28,8 +27,7 @@ class AndroidDeviceCodecs private constructor( private val decodeMimes: Set, ) : DeviceCodecs { - override fun canEncode(codec: VideoCodec): Boolean = - mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true + override fun canEncode(codec: VideoCodec): Boolean = mimeFor(codec)?.let { it in hardwareEncodeMimes } ?: true override fun canDecode(codecName: String): Boolean { // The platform already failed to parse this input, so there is nothing to @@ -46,8 +44,7 @@ class AndroidDeviceCodecs private constructor( @Volatile private var cached: AndroidDeviceCodecs? = null - fun get(): AndroidDeviceCodecs = - cached ?: synchronized(this) { cached ?: probe().also { cached = it } } + fun get(): AndroidDeviceCodecs = cached ?: synchronized(this) { cached ?: probe().also { cached = it } } private fun probe(): AndroidDeviceCodecs { val encoders = mutableSetOf() @@ -104,7 +101,6 @@ class AndroidDeviceCodecs private constructor( } /** Test seam: lets instrumented tests build a probe from explicit sets. */ - fun forTesting(encoders: Set, decoders: Set) = - AndroidDeviceCodecs(encoders, decoders) + fun forTesting(encoders: Set, decoders: Set) = AndroidDeviceCodecs(encoders, decoders) } } diff --git a/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt b/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt index e53a045..4e2ed51 100644 --- a/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt +++ b/app/src/main/java/org/libremediaconverter/convert/ConversionViewModel.kt @@ -8,17 +8,6 @@ import androidx.lifecycle.viewModelScope import androidx.media3.common.util.UnstableApi import androidx.work.WorkInfo import androidx.work.WorkManager -import org.libremediaconverter.model.AudioCodec -import org.libremediaconverter.model.Container -import org.libremediaconverter.model.ContainerCapabilities -import org.libremediaconverter.model.EnginePreference -import org.libremediaconverter.model.InputProbe -import org.libremediaconverter.model.OutputFormat -import org.libremediaconverter.model.OutputSpec -import org.libremediaconverter.model.QualityTier -import org.libremediaconverter.model.Validation -import org.libremediaconverter.model.VideoCodec -import org.libremediaconverter.work.ConversionWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -30,6 +19,17 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.Container +import org.libremediaconverter.model.ContainerCapabilities +import org.libremediaconverter.model.EnginePreference +import org.libremediaconverter.model.InputProbe +import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.OutputSpec +import org.libremediaconverter.model.QualityTier +import org.libremediaconverter.model.Validation +import org.libremediaconverter.model.VideoCodec +import org.libremediaconverter.work.ConversionWorker import java.io.File import java.util.UUID @@ -62,6 +62,7 @@ sealed interface ConversionState { data object Idle : ConversionState data class Ready(val input: InputFile) : ConversionState data class Converting(val input: InputFile, val percent: Int) : ConversionState + /** Budget for foreground work ran out; WorkManager will retry when it can. */ data class Waiting(val input: InputFile) : ConversionState data class Converted( @@ -102,20 +103,16 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { }.stateIn(viewModelScope, SharingStarted.Eagerly, Validation.Valid) fun setPreset(format: OutputFormat) = _settings.update { it.copy(spec = format.spec) } - fun setContainer(container: Container) = - _settings.update { it.copy(spec = it.spec.copy(container = container)) } + fun setContainer(container: Container) = _settings.update { it.copy(spec = it.spec.copy(container = container)) } - fun setVideoCodec(codec: VideoCodec) = - _settings.update { it.copy(spec = it.spec.copy(videoCodec = codec)) } + fun setVideoCodec(codec: VideoCodec) = _settings.update { it.copy(spec = it.spec.copy(videoCodec = codec)) } - fun setAudioCodec(codec: AudioCodec) = - _settings.update { it.copy(spec = it.spec.copy(audioCodec = codec)) } + fun setAudioCodec(codec: AudioCodec) = _settings.update { it.copy(spec = it.spec.copy(audioCodec = codec)) } fun applySuggestion(spec: OutputSpec) = _settings.update { it.copy(spec = spec) } fun setQuality(quality: QualityTier) = _settings.update { it.copy(quality = quality) } - fun setEnginePreference(preference: EnginePreference) = - _settings.update { it.copy(enginePreference = preference) } + fun setEnginePreference(preference: EnginePreference) = _settings.update { it.copy(enginePreference = preference) } fun onInputPicked(uri: Uri) { viewModelScope.launch { @@ -202,7 +199,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { WorkInfo.State.FAILED -> ConversionState.Failed( info.outputData.getString(ConversionWorker.KEY_ERROR) - ?: "Conversion failed." + ?: "Conversion failed.", ) WorkInfo.State.CANCELLED -> ConversionState.Ready(input) @@ -229,7 +226,7 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { ConversionWorker.outputNameFor( converted.input.displayName, _settings.value.spec, - ) + ), ) }.onFailure { e -> _state.value = ConversionState.Failed(e.message ?: "Could not save the file.") @@ -244,11 +241,10 @@ class ConversionViewModel(app: Application) : AndroidViewModel(app) { _state.value = ConversionState.Idle } - fun suggestedOutputName(): String = - ConversionWorker.outputNameFor( - currentInput()?.displayName ?: "output", - _settings.value.spec, - ) + fun suggestedOutputName(): String = ConversionWorker.outputNameFor( + currentInput()?.displayName ?: "output", + _settings.value.spec, + ) private fun ConversionState.probe(): InputProbe? = when (this) { is ConversionState.Ready -> input.probe diff --git a/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt b/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt index 12c999f..a68838d 100644 --- a/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt +++ b/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt @@ -34,9 +34,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import org.libremediaconverter.ui.PrimaryButtonHeight -import org.libremediaconverter.ui.ScreenPaddingHorizontal -import org.libremediaconverter.ui.ScreenPaddingVertical import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.media3.common.util.UnstableApi @@ -50,14 +47,14 @@ import org.libremediaconverter.model.OutputSpec import org.libremediaconverter.model.QualityTier import org.libremediaconverter.model.Validation import org.libremediaconverter.model.VideoCodec +import org.libremediaconverter.ui.PrimaryButtonHeight +import org.libremediaconverter.ui.ScreenPaddingHorizontal +import org.libremediaconverter.ui.ScreenPaddingVertical import java.util.Locale @UnstableApi @Composable -fun ConverterScreen( - modifier: Modifier = Modifier, - viewModel: ConversionViewModel = viewModel(), -) { +fun ConverterScreen(modifier: Modifier = Modifier, viewModel: ConversionViewModel = viewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle() val validation by viewModel.validation.collectAsStateWithLifecycle() @@ -66,11 +63,11 @@ fun ConverterScreen( // only, offers no audio at all, and will not reliably surface .mkv/.flac/.webm. // SAF needs no runtime permission. val pickInput = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument() + ActivityResultContracts.OpenDocument(), ) { uri -> uri?.let(viewModel::onInputPicked) } val chooseDestination = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument(settings.spec.mimeType) + ActivityResultContracts.CreateDocument(settings.spec.mimeType), ) { uri -> uri?.let(viewModel::save) } // Requested at the point of use rather than on first launch, so the ask carries its @@ -78,7 +75,7 @@ fun ConverterScreen( // foreground service still runs, but its progress notification is confined to the // Task Manager instead of the shade. val requestNotifications = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() + ActivityResultContracts.RequestPermission(), ) { viewModel.convert() } Column( diff --git a/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt b/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt index 71d1b79..fb25f71 100644 --- a/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt +++ b/app/src/main/java/org/libremediaconverter/convert/Media3Engine.kt @@ -69,7 +69,10 @@ class Media3Engine(private val context: Context) : HardwareTranscoder { val plan = CopyPlanner.plan(request.spec, request.probe) handler.post { val transformer = runCatching { buildTransformer(plan, cont) } - .getOrElse { cont.resumeWithException(it); return@post } + .getOrElse { + cont.resumeWithException(it) + return@post + } // Dropping the tracks the target does not have is what stops an audio-only export // from carrying a re-encoded video track. Without setRemoveVideo, asking for M4A @@ -92,7 +95,10 @@ class Media3Engine(private val context: Context) : HardwareTranscoder { } runCatching { transformer.start(composition, output.absolutePath) } - .onFailure { cont.resumeWithException(it); return@post } + .onFailure { + cont.resumeWithException(it) + return@post + } pollProgress(transformer, cont, onProgress) } @@ -104,10 +110,7 @@ class Media3Engine(private val context: Context) : HardwareTranscoder { * is supposed to have sent such a job to FFmpeg — so it fails loudly instead of quietly * writing MP4, which is what the old code did. */ - private fun buildTransformer( - plan: ConversionPlan, - cont: CancellableContinuation, - ): Transformer { + private fun buildTransformer(plan: ConversionPlan, cont: CancellableContinuation): Transformer { val muxerFactory = requireNotNull(Media3Muxers.factoryFor(plan.container)) { "Media3 cannot mux ${plan.container}; this job should have routed to FFmpeg." } @@ -128,11 +131,7 @@ class Media3Engine(private val context: Context) : HardwareTranscoder { if (cont.isActive) cont.resume(Unit) } - override fun onError( - composition: Composition, - result: ExportResult, - exception: ExportException, - ) { + override fun onError(composition: Composition, result: ExportResult, exception: ExportException) { if (cont.isActive) cont.resumeWithException(exception) } }) @@ -193,7 +192,6 @@ class Media3Engine(private val context: Context) : HardwareTranscoder { thread.quitSafely() } - private companion object { const val PROGRESS_INTERVAL_MS = 250L } diff --git a/app/src/main/java/org/libremediaconverter/convert/OutputPublisher.kt b/app/src/main/java/org/libremediaconverter/convert/OutputPublisher.kt index af8973e..f0cbe30 100644 --- a/app/src/main/java/org/libremediaconverter/convert/OutputPublisher.kt +++ b/app/src/main/java/org/libremediaconverter/convert/OutputPublisher.kt @@ -32,8 +32,7 @@ open class OutputPublisher(private val context: Context) { * Staging means peak usage is roughly input + output at once, so a job that would * just barely fit is rejected rather than failing partway through. */ - open fun hasSpaceFor(bytes: Long): Boolean = - stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES + open fun hasSpaceFor(bytes: Long): Boolean = stagingDir.usableSpace > bytes + SPACE_HEADROOM_BYTES /** Copies a finished staging file into a user-chosen SAF destination. */ open fun publish(staged: File, destination: Uri) { diff --git a/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt b/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt index ea7f810..679b0a5 100644 --- a/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt +++ b/app/src/main/java/org/libremediaconverter/convert/Transcoders.kt @@ -2,11 +2,11 @@ package org.libremediaconverter.convert import android.content.Context import android.net.Uri +import org.libremediaconverter.codec.AndroidDeviceCodecs import org.libremediaconverter.ffmpeg.FFmpegEngine import org.libremediaconverter.model.ConversionRequest import org.libremediaconverter.model.DeviceCodecs import org.libremediaconverter.model.OutputFormat -import org.libremediaconverter.codec.AndroidDeviceCodecs import java.io.File /** The hardware conversion path. Implemented by [Media3Engine]. */ diff --git a/app/src/main/java/org/libremediaconverter/ffmpeg/ConcatEngine.kt b/app/src/main/java/org/libremediaconverter/ffmpeg/ConcatEngine.kt index 038df54..35ea0de 100644 --- a/app/src/main/java/org/libremediaconverter/ffmpeg/ConcatEngine.kt +++ b/app/src/main/java/org/libremediaconverter/ffmpeg/ConcatEngine.kt @@ -6,11 +6,11 @@ import android.util.Log import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegKitConfig import com.arthenica.ffmpegkit.ReturnCode +import kotlinx.coroutines.suspendCancellableCoroutine import org.libremediaconverter.convert.MediaProbe import org.libremediaconverter.model.ConcatPlanner import org.libremediaconverter.model.ConcatStrategy import org.libremediaconverter.model.OutputFormat -import kotlinx.coroutines.suspendCancellableCoroutine import java.io.File import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -27,11 +27,7 @@ class ConcatEngine(private val context: Context) { data class Result(val strategy: ConcatStrategy, val output: File) - suspend fun join( - inputs: List, - output: File, - format: OutputFormat = OutputFormat.MP4_H264, - ): Result { + suspend fun join(inputs: List, output: File, format: OutputFormat = OutputFormat.MP4_H264): Result { require(inputs.size >= 2) { "Joining needs at least two files." } val paths = inputs.map { uri -> @@ -70,8 +66,8 @@ class ConcatEngine(private val context: Context) { else -> cont.resumeWithException( FFmpegEngine.FFmpegException( "Joining failed (${rc?.value}): " + - completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty() - ) + completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty(), + ), ) } } diff --git a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt index 4dde3ba..a06206b 100644 --- a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt +++ b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt @@ -53,17 +53,14 @@ object FFmpegCommandBuilder { /** Containers in the ISO base-media family, where HEVC needs the hvc1 brand. */ private val MP4_FAMILY = setOf(Container.MP4, Container.MOV) - fun build( - request: ConversionRequest, - inputPath: String, - outputPath: String, - ): List { + fun build(request: ConversionRequest, inputPath: String, outputPath: String): List { val plan = CopyPlanner.plan(request.spec, request.probe) return buildList { add("-hide_banner") // Overwrite: the output path is one we just created in our own cache. add("-y") - add("-i"); add(inputPath) + add("-i") + add(inputPath) if (request.spec.isImageOutput) { addAll(imageArgs(request)) @@ -86,7 +83,8 @@ object FFmpegCommandBuilder { "-vf", "fps=12,scale=480:-1:flags=lanczos,split[a][b];" + "[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer", - "-loop", "0", + "-loop", + "0", ) else -> listOf("-an", "-vf", "fps=1", "-vsync", "0") @@ -99,7 +97,8 @@ object FFmpegCommandBuilder { VideoPlan.Drop -> listOf("-vn") VideoPlan.Copy -> buildList { - add("-c:v"); add("copy") + add("-c:v") + add("copy") // The hvc1 brand matters on the copy path too, not just when encoding: remuxing // HEVC out of Matroska into MP4 otherwise produces a file Apple devices and many // hardware players refuse, even though the samples are byte-identical. @@ -128,9 +127,15 @@ object FFmpegCommandBuilder { // that hardware encoding is unlikely to work for that input. Fast therefore means a // fast *preset*, not a different encoder. VideoCodec.H265 -> listOf( - "-c:v", "libx265", "-crf", "$CRF_H265", "-preset", preset, + "-c:v", + "libx265", + "-crf", + "$CRF_H265", + "-preset", + preset, // Without this, many players and Apple devices refuse HEVC in MP4. - "-tag:v", "hvc1", + "-tag:v", + "hvc1", ) + PIX_FMT VideoCodec.VP9 -> buildList { @@ -140,7 +145,12 @@ object FFmpegCommandBuilder { } VideoCodec.H264 -> listOf( - "-c:v", "libx264", "-crf", "$CRF_H264", "-preset", preset, + "-c:v", + "libx264", + "-crf", + "$CRF_H264", + "-preset", + preset, ) + PIX_FMT // No silent substitution. A trailing `else -> libx264` would hand back H.264 for a @@ -151,11 +161,11 @@ object FFmpegCommandBuilder { // about rather than papering over. VideoCodec.VP8, VideoCodec.AV1 -> error( "This app cannot encode ${codec.label}; it can only copy an existing " + - "${codec.label} stream." + "${codec.label} stream.", ) VideoCodec.COPY, VideoCodec.NONE -> error( - "encodeVideo called for $codec, which is not an encode" + "encodeVideo called for $codec, which is not an encode", ) } } @@ -167,31 +177,32 @@ object FFmpegCommandBuilder { return if (sourceIsHevc) listOf("-tag:v", "hvc1") else emptyList() } - private fun audioArgs(plan: ConversionPlan): List = - when (val audio = plan.audio) { - AudioPlan.Drop -> listOf("-an") - AudioPlan.Copy -> listOf("-c:a", "copy") - is AudioPlan.Encode -> when (audio.codec) { - AudioCodec.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2") - AudioCodec.FLAC -> listOf("-c:a", "flac") - AudioCodec.PCM -> listOf("-c:a", "pcm_s16le") - AudioCodec.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k") - AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5") - else -> listOf("-c:a", "aac", "-b:a", "192k") - } + private fun audioArgs(plan: ConversionPlan): List = when (val audio = plan.audio) { + AudioPlan.Drop -> listOf("-an") + AudioPlan.Copy -> listOf("-c:a", "copy") + is AudioPlan.Encode -> when (audio.codec) { + AudioCodec.MP3 -> listOf("-c:a", "libmp3lame", "-q:a", "2") + AudioCodec.FLAC -> listOf("-c:a", "flac") + AudioCodec.PCM -> listOf("-c:a", "pcm_s16le") + AudioCodec.OPUS -> listOf("-c:a", "libopus", "-b:a", "128k") + AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5") + else -> listOf("-c:a", "aac", "-b:a", "192k") } + } private fun containerArgs(plan: ConversionPlan): List = buildList { // Name the muxer rather than letting FFmpeg infer it from the output path. Inference is // unreliable for MPEG-TS and ASF, and the app now lets the user pick a container // independently of the preset that used to imply it. - add("-f"); add(plan.container.ffmpegFormat) + add("-f") + add(plan.container.ffmpegFormat) if (plan.container in MP4_FAMILY) { // Move the moov atom to the front so the file starts playing before it is // fully downloaded. This is also the reason output never goes through a SAF // file descriptor: faststart has to seek backwards to rewrite the header. - add("-movflags"); add("+faststart") + add("-movflags") + add("+faststart") } } diff --git a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommand.kt b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommand.kt index 4b35fb0..ec939b6 100644 --- a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommand.kt +++ b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommand.kt @@ -28,21 +28,31 @@ object FFmpegConcatCommand { format: OutputFormat, ): List = when (strategy) { ConcatStrategy.STREAM_COPY -> buildList { - add("-hide_banner"); add("-y") + add("-hide_banner") + add("-y") // -safe 0 permits absolute paths in the list file, which ours are. - add("-f"); add("concat") - add("-safe"); add("0") - add("-i"); add(listFile.absolutePath) - add("-c"); add("copy") + add("-f") + add("concat") + add("-safe") + add("0") + add("-i") + add(listFile.absolutePath) + add("-c") + add("copy") if (format.container == org.libremediaconverter.model.Container.MP4) { - add("-movflags"); add("+faststart") + add("-movflags") + add("+faststart") } add(output.absolutePath) } ConcatStrategy.REENCODE -> buildList { - add("-hide_banner"); add("-y") - inputPaths.forEach { add("-i"); add(it) } + add("-hide_banner") + add("-y") + inputPaths.forEach { + add("-i") + add(it) + } // Normalise every input to a common size and frame rate before joining, // otherwise the concat filter refuses mismatched inputs. val filter = buildString { @@ -53,15 +63,23 @@ object FFmpegConcatCommand { inputPaths.indices.forEach { i -> append("[v$i][$i:a]") } append("concat=n=${inputPaths.size}:v=1:a=1[v][a]") } - add("-filter_complex"); add(filter) - add("-map"); add("[v]") - add("-map"); add("[a]") - add("-c:v"); add("libx264") - add("-crf"); add("20") - add("-pix_fmt"); add("yuv420p") - add("-c:a"); add("aac") + add("-filter_complex") + add(filter) + add("-map") + add("[v]") + add("-map") + add("[a]") + add("-c:v") + add("libx264") + add("-crf") + add("20") + add("-pix_fmt") + add("yuv420p") + add("-c:a") + add("aac") if (format.container == org.libremediaconverter.model.Container.MP4) { - add("-movflags"); add("+faststart") + add("-movflags") + add("+faststart") } add(output.absolutePath) } diff --git a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegEngine.kt b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegEngine.kt index 6205b52..f830ca8 100644 --- a/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegEngine.kt +++ b/app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegEngine.kt @@ -5,9 +5,9 @@ import com.arthenica.ffmpegkit.FFmpegKit import com.arthenica.ffmpegkit.FFmpegKitConfig import com.arthenica.ffmpegkit.Level import com.arthenica.ffmpegkit.ReturnCode +import kotlinx.coroutines.suspendCancellableCoroutine import org.libremediaconverter.convert.SoftwareTranscoder import org.libremediaconverter.model.ConversionRequest -import kotlinx.coroutines.suspendCancellableCoroutine import java.io.File import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -61,8 +61,8 @@ class FFmpegEngine : SoftwareTranscoder { "FFmpeg failed (${rc?.value}): " + completed.getFailStackTrace().orEmpty().ifBlank { completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty() - } - ) + }, + ), ) } }, diff --git a/app/src/main/java/org/libremediaconverter/join/JoinScreen.kt b/app/src/main/java/org/libremediaconverter/join/JoinScreen.kt index e344054..e5b6707 100644 --- a/app/src/main/java/org/libremediaconverter/join/JoinScreen.kt +++ b/app/src/main/java/org/libremediaconverter/join/JoinScreen.kt @@ -33,18 +33,15 @@ import org.libremediaconverter.ui.ScreenPaddingVertical @UnstableApi @Composable -fun JoinScreen( - modifier: Modifier = Modifier, - viewModel: JoinViewModel = viewModel(), -) { +fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() val pickInputs = rememberLauncherForActivityResult( - ActivityResultContracts.OpenMultipleDocuments() + ActivityResultContracts.OpenMultipleDocuments(), ) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) } val chooseDestination = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("video/mp4") + ActivityResultContracts.CreateDocument("video/mp4"), ) { uri -> uri?.let(viewModel::save) } Column( diff --git a/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt b/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt index 52c192c..2d09e3f 100644 --- a/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt +++ b/app/src/main/java/org/libremediaconverter/join/JoinViewModel.kt @@ -8,10 +8,6 @@ import androidx.lifecycle.viewModelScope import androidx.media3.common.util.UnstableApi import androidx.work.WorkInfo import androidx.work.WorkManager -import org.libremediaconverter.convert.InputFile -import org.libremediaconverter.convert.OutputPublisher -import org.libremediaconverter.model.ConcatStrategy -import org.libremediaconverter.work.ConcatWorker import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -19,6 +15,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.libremediaconverter.convert.InputFile +import org.libremediaconverter.convert.OutputPublisher +import org.libremediaconverter.model.ConcatStrategy +import org.libremediaconverter.work.ConcatWorker import java.io.File import java.util.UUID @@ -72,8 +72,11 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) { _state.value = when (info.state) { WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs) WorkInfo.State.ENQUEUED -> - if (info.runAttemptCount > 0) JoinState.Waiting(inputs) - else JoinState.Joining(inputs) + if (info.runAttemptCount > 0) { + JoinState.Waiting(inputs) + } else { + JoinState.Joining(inputs) + } WorkInfo.State.SUCCEEDED -> { val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH) @@ -87,7 +90,7 @@ class JoinViewModel(app: Application) : AndroidViewModel(app) { } WorkInfo.State.FAILED -> JoinState.Failed( - info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed." + info.outputData.getString(ConcatWorker.KEY_ERROR) ?: "Joining failed.", ) WorkInfo.State.CANCELLED -> JoinState.Ready(inputs) diff --git a/app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt b/app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt index e5c3644..4ac19b4 100644 --- a/app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt +++ b/app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt @@ -27,7 +27,11 @@ object ContainerCapabilities { // Matroska is the permissive one: it is a general-purpose container and takes essentially // any codec. That is what makes it the natural remux target. Container.MKV to setOf( - VideoCodec.H264, VideoCodec.H265, VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1, + VideoCodec.H264, + VideoCodec.H265, + VideoCodec.VP8, + VideoCodec.VP9, + VideoCodec.AV1, ), Container.WEBM to setOf(VideoCodec.VP8, VideoCodec.VP9, VideoCodec.AV1), Container.MPEG_TS to setOf(VideoCodec.H264, VideoCodec.H265), @@ -48,8 +52,12 @@ object ContainerCapabilities { Container.MP4 to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.OPUS, AudioCodec.FLAC), Container.MOV to setOf(AudioCodec.AAC, AudioCodec.MP3, AudioCodec.PCM), Container.MKV to setOf( - AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS, - AudioCodec.MP3, AudioCodec.FLAC, AudioCodec.PCM, + AudioCodec.AAC, + AudioCodec.OPUS, + AudioCodec.VORBIS, + AudioCodec.MP3, + AudioCodec.FLAC, + AudioCodec.PCM, ), Container.WEBM to setOf(AudioCodec.OPUS, AudioCodec.VORBIS), Container.MPEG_TS to setOf(AudioCodec.AAC, AudioCodec.MP3), @@ -75,7 +83,11 @@ object ContainerCapabilities { /** Vorbis is absent for the same reason: nothing here emits a Vorbis encoder. */ private val ENCODABLE_AUDIO = setOf( - AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.MP3, AudioCodec.FLAC, AudioCodec.PCM, + AudioCodec.AAC, + AudioCodec.OPUS, + AudioCodec.MP3, + AudioCodec.FLAC, + AudioCodec.PCM, ) fun accepts(container: Container, codec: VideoCodec, mode: CodecMode): Boolean = when (codec) { diff --git a/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt b/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt index 31af371..7b423c2 100644 --- a/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt +++ b/app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt @@ -57,7 +57,10 @@ object ConversionRouter { internal val MEDIA3_MUXABLE_AUDIO: Map> = mapOf( Container.MP4 to setOf( - AudioCodec.AAC, AudioCodec.OPUS, AudioCodec.VORBIS, AudioCodec.PCM, + AudioCodec.AAC, + AudioCodec.OPUS, + AudioCodec.VORBIS, + AudioCodec.PCM, ), ) diff --git a/app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt b/app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt index 3542944..fc6aed4 100644 --- a/app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt +++ b/app/src/main/java/org/libremediaconverter/model/CopyPlanner.kt @@ -18,14 +18,11 @@ sealed interface AudioPlan { /** * A resolved conversion: no [VideoCodec.COPY] left to interpret, every track decided. */ -data class ConversionPlan( - val container: Container, - val video: VideoPlan, - val audio: AudioPlan, -) { +data class ConversionPlan(val container: Container, val video: VideoPlan, val audio: AudioPlan) { /** No track is re-encoded and at least one is copied: a container change and nothing more. */ val isPureRemux: Boolean - get() = video !is VideoPlan.Encode && audio !is AudioPlan.Encode && + get() = video !is VideoPlan.Encode && + audio !is AudioPlan.Encode && (video is VideoPlan.Copy || audio is AudioPlan.Copy) val hasVideo: Boolean get() = video != VideoPlan.Drop diff --git a/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt b/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt index 1ff906e..8e5b23f 100644 --- a/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt +++ b/app/src/main/java/org/libremediaconverter/model/OutputFormat.kt @@ -45,11 +45,9 @@ enum class Container( /** Whether this container can hold a video track at all. */ val canHoldVideo: Boolean get() = videoExtension != null - fun extensionFor(hasVideo: Boolean): String = - if (hasVideo) videoExtension ?: audioExtension else audioExtension + fun extensionFor(hasVideo: Boolean): String = if (hasVideo) videoExtension ?: audioExtension else audioExtension - fun mimeTypeFor(hasVideo: Boolean): String = - if (hasVideo) videoMime ?: audioMime else audioMime + fun mimeTypeFor(hasVideo: Boolean): String = if (hasVideo) videoMime ?: audioMime else audioMime } /** @@ -88,11 +86,7 @@ enum class AudioCodec(val label: String) { * now comes from [ContainerCapabilities] instead, which is explicit and unit-tested rather than * implicit in which combinations someone remembered to enumerate. */ -data class OutputSpec( - val container: Container, - val videoCodec: VideoCodec, - val audioCodec: AudioCodec, -) { +data class OutputSpec(val container: Container, val videoCodec: VideoCodec, val audioCodec: AudioCodec) { /** Whether the output keeps a video track — the thing extension and MIME type turn on. */ val hasVideo: Boolean get() = videoCodec != VideoCodec.NONE @@ -104,7 +98,8 @@ data class OutputSpec( /** True when neither track is re-encoded, i.e. this is a pure container change. */ val isPureRemux: Boolean - get() = videoCodec.isCopyOrAbsent() && audioCodec.isCopyOrAbsent() && + get() = videoCodec.isCopyOrAbsent() && + audioCodec.isCopyOrAbsent() && (videoCodec == VideoCodec.COPY || audioCodec == AudioCodec.COPY) val extension: String get() = container.extensionFor(hasVideo) diff --git a/app/src/main/java/org/libremediaconverter/ui/theme/Theme.kt b/app/src/main/java/org/libremediaconverter/ui/theme/Theme.kt index 856247a..704cb6e 100644 --- a/app/src/main/java/org/libremediaconverter/ui/theme/Theme.kt +++ b/app/src/main/java/org/libremediaconverter/ui/theme/Theme.kt @@ -1,6 +1,5 @@ package org.libremediaconverter.ui.theme -import android.app.Activity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme diff --git a/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt b/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt index 7b20b6b..8511d25 100644 --- a/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt +++ b/app/src/main/java/org/libremediaconverter/work/ConcatWorker.kt @@ -8,7 +8,6 @@ import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkInfo import androidx.work.WorkerParameters import androidx.work.workDataOf import org.libremediaconverter.convert.ConversionDependencies @@ -27,10 +26,7 @@ import org.libremediaconverter.model.OutputFormat * concatenated; showing a fabricated percentage would be worse than showing none. */ @UnstableApi -class ConcatWorker( - context: Context, - params: WorkerParameters, -) : CoroutineWorker(context, params) { +class ConcatWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { private val notifications = ConversionNotifications(applicationContext) private val publisher = ConversionDependencies.publisher(applicationContext) @@ -43,7 +39,7 @@ class ConcatWorker( } val totalBytes = inputData.getLong(KEY_TOTAL_BYTES, 0L) val format = OutputFormat.valueOf( - inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name + inputData.getString(KEY_FORMAT) ?: OutputFormat.MP4_H264.name, ) if (!publisher.hasSpaceFor(totalBytes)) { @@ -55,7 +51,7 @@ class ConcatWorker( NOTIFICATION_ID, notifications.build(id, "Joining ${uris.size} files", 0, indeterminate = true), ConversionForegroundType.current(), - ) + ), ) val staged = publisher.createStagingFile("joined.${format.extension}") @@ -65,7 +61,7 @@ class ConcatWorker( workDataOf( KEY_OUTPUT_PATH to staged.absolutePath, KEY_STRATEGY to result.strategy.name, - ) + ), ) } catch (e: Throwable) { staged.delete() @@ -99,18 +95,15 @@ class ConcatWorker( private const val NOTIFICATION_ID = 1002 private const val TAG = "ConcatWorker" - fun request( - inputs: List, - totalBytes: Long, - format: OutputFormat = OutputFormat.MP4_H264, - ) = OneTimeWorkRequestBuilder() - .setInputData( - Data.Builder() - .putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray()) - .putLong(KEY_TOTAL_BYTES, totalBytes) - .putString(KEY_FORMAT, format.name) - .build() - ) - .build() + fun request(inputs: List, totalBytes: Long, format: OutputFormat = OutputFormat.MP4_H264) = + OneTimeWorkRequestBuilder() + .setInputData( + Data.Builder() + .putStringArray(KEY_INPUT_URIS, inputs.map(Uri::toString).toTypedArray()) + .putLong(KEY_TOTAL_BYTES, totalBytes) + .putString(KEY_FORMAT, format.name) + .build(), + ) + .build() } } diff --git a/app/src/main/java/org/libremediaconverter/work/ConversionNotifications.kt b/app/src/main/java/org/libremediaconverter/work/ConversionNotifications.kt index e2b78b8..c4e14da 100644 --- a/app/src/main/java/org/libremediaconverter/work/ConversionNotifications.kt +++ b/app/src/main/java/org/libremediaconverter/work/ConversionNotifications.kt @@ -35,7 +35,7 @@ class ConversionNotifications(private val context: Context) { context.getString(R.string.notification_preparing) } else { context.getString(R.string.notification_progress, percent) - } + }, ) .setSmallIcon(android.R.drawable.stat_sys_download) .setOngoing(true) @@ -57,10 +57,9 @@ class ConversionNotifications(private val context: Context) { * notification appears only in the Task Manager rather than the shade — so * progress silently vanishes from the user's point of view. */ - fun areEnabled(): Boolean = - context.getSystemService(NotificationManager::class.java) - .areNotificationsEnabled() - .also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") } + fun areEnabled(): Boolean = context.getSystemService(NotificationManager::class.java) + .areNotificationsEnabled() + .also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") } companion object { const val CHANNEL_ID = "conversions" diff --git a/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt b/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt index ea28f12..4b835fd 100644 --- a/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt +++ b/app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt @@ -8,7 +8,6 @@ import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkInfo import androidx.work.WorkerParameters import androidx.work.workDataOf import com.arthenica.ffmpegkit.FFmpegKitConfig @@ -40,12 +39,10 @@ import java.io.File * with a short quota, which is the wrong shape for a multi-minute transcode. */ @UnstableApi -class ConversionWorker( - context: Context, - params: WorkerParameters, -) : CoroutineWorker(context, params) { +class ConversionWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { private val notifications = ConversionNotifications(applicationContext) + // Resolved through ConversionDependencies so tests can force the failure paths. private val publisher = ConversionDependencies.publisher(applicationContext) @@ -56,10 +53,10 @@ class ConversionWorker( val sizeBytes = inputData.getLong(KEY_SIZE_BYTES, 0L) val spec = readSpec() val quality = QualityTier.valueOf( - inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name + inputData.getString(KEY_QUALITY) ?: QualityTier.FAST.name, ) val preference = EnginePreference.valueOf( - inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name + inputData.getString(KEY_ENGINE_PREFERENCE) ?: EnginePreference.AUTO.name, ) if (!publisher.hasSpaceFor(sizeBytes)) { @@ -102,7 +99,7 @@ class ConversionWorker( KEY_OUTPUT_PATH to staged.absolutePath, KEY_ENGINE_USED to decision.engine.name, KEY_ROUTE_REASON to decision.reason.explanation, - ) + ), ) } catch (e: Throwable) { staged.delete() @@ -142,12 +139,7 @@ class ConversionWorker( runFFmpeg(request, inputUri, staged, displayName) } - private suspend fun runFFmpeg( - request: ConversionRequest, - inputUri: Uri, - staged: File, - displayName: String, - ) { + private suspend fun runFFmpeg(request: ConversionRequest, inputUri: Uri, staged: File, displayName: String) { // FFmpeg needs a path. ffkitsaf bridges a content:// URI for reading; the read // side is seekable for local providers, which is all the demuxer needs. Output // still goes to a real cache path — see OutputPublisher. @@ -177,8 +169,7 @@ class ConversionWorker( } } - private fun isCancellation(e: Throwable): Boolean = - e is kotlinx.coroutines.CancellationException || isStopped + private fun isCancellation(e: Throwable): Boolean = e is kotlinx.coroutines.CancellationException || isStopped /** * Distinguishes a genuine failure from the foreground-service budget expiring. @@ -189,17 +180,16 @@ class ConversionWorker( * later rather than tell the user the conversion failed — the work is still valid, * there is simply no budget right now. */ - private fun handleTimeoutIfNeeded(cause: Throwable): Result = - when (FailureOutcome.forStopReason(stopReason)) { - FailureOutcome.RETRY -> { - Log.w(TAG, "Foreground service budget exhausted; will retry.", cause) - Result.retry() - } - FailureOutcome.FAIL -> { - Log.e(TAG, "Conversion failed.", cause) - Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed."))) - } + private fun handleTimeoutIfNeeded(cause: Throwable): Result = when (FailureOutcome.forStopReason(stopReason)) { + FailureOutcome.RETRY -> { + Log.w(TAG, "Foreground service budget exhausted; will retry.", cause) + Result.retry() } + FailureOutcome.FAIL -> { + Log.e(TAG, "Conversion failed.", cause) + Result.failure(workDataOf(KEY_ERROR to (cause.message ?: "Conversion failed."))) + } + } /** * Reads the output spec out of the worker's input Data. @@ -223,19 +213,17 @@ class ConversionWorker( return OutputSpec(container, video, audio) } - override suspend fun getForegroundInfo(): ForegroundInfo = - foregroundInfo( - inputData.getString(KEY_DISPLAY_NAME) ?: "input", - percent = 0, - indeterminate = true, - ) + override suspend fun getForegroundInfo(): ForegroundInfo = foregroundInfo( + inputData.getString(KEY_DISPLAY_NAME) ?: "input", + percent = 0, + indeterminate = true, + ) - private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) = - ForegroundInfo( - NOTIFICATION_ID, - notifications.build(id, title, percent, indeterminate), - ConversionForegroundType.current(), - ) + private fun foregroundInfo(title: String, percent: Int, indeterminate: Boolean) = ForegroundInfo( + NOTIFICATION_ID, + notifications.build(id, title, percent, indeterminate), + ConversionForegroundType.current(), + ) companion object { const val KEY_INPUT_URI = "input_uri" @@ -263,9 +251,8 @@ class ConversionWorker( * yields `.mkv` or `.mka` and MP4 yields `.mp4` or `.m4a` without a preset having to * enumerate both. */ - fun outputNameFor(inputName: String, spec: OutputSpec): String = - inputName.substringBeforeLast('.', inputName) + - "_converted.${spec.extension}" + fun outputNameFor(inputName: String, spec: OutputSpec): String = inputName.substringBeforeLast('.', inputName) + + "_converted.${spec.extension}" fun request( inputUri: Uri, @@ -285,7 +272,7 @@ class ConversionWorker( .putString(KEY_AUDIO_CODEC, spec.audioCodec.name) .putString(KEY_QUALITY, quality.name) .putString(KEY_ENGINE_PREFERENCE, enginePreference.name) - .build() + .build(), ) .build() } diff --git a/app/src/main/java/org/libremediaconverter/work/FailureOutcome.kt b/app/src/main/java/org/libremediaconverter/work/FailureOutcome.kt index 123cc36..ab84b96 100644 --- a/app/src/main/java/org/libremediaconverter/work/FailureOutcome.kt +++ b/app/src/main/java/org/libremediaconverter/work/FailureOutcome.kt @@ -16,7 +16,9 @@ enum class FailureOutcome { RETRY, /** A genuine failure; report it to the user. */ - FAIL; + FAIL, + + ; companion object { fun forStopReason(stopReason: Int): FailureOutcome = diff --git a/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt b/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt index f119133..936717a 100644 --- a/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt +++ b/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilderTest.kt @@ -1,17 +1,17 @@ package org.libremediaconverter.ffmpeg -import org.libremediaconverter.model.Container -import org.libremediaconverter.model.ConversionRequest -import org.libremediaconverter.model.InputProbe -import org.libremediaconverter.model.OutputFormat -import org.libremediaconverter.model.AudioCodec -import org.libremediaconverter.model.OutputSpec -import org.libremediaconverter.model.VideoCodec -import org.libremediaconverter.model.QualityTier import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.libremediaconverter.model.AudioCodec +import org.libremediaconverter.model.Container +import org.libremediaconverter.model.ConversionRequest +import org.libremediaconverter.model.InputProbe +import org.libremediaconverter.model.OutputFormat +import org.libremediaconverter.model.OutputSpec +import org.libremediaconverter.model.QualityTier +import org.libremediaconverter.model.VideoCodec class FFmpegCommandBuilderTest { @@ -94,8 +94,10 @@ class FFmpegCommandBuilderTest { @Test fun `every video encode path forces yuv420p`() { val videoFormats = listOf( - OutputFormat.MP4_H264, OutputFormat.MP4_H265, - OutputFormat.MKV_H264, OutputFormat.MKV_H265, + OutputFormat.MP4_H264, + OutputFormat.MP4_H265, + OutputFormat.MKV_H264, + OutputFormat.MKV_H265, OutputFormat.WEBM_VP9, ) videoFormats.forEach { format -> diff --git a/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommandTest.kt b/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommandTest.kt index b596435..c5b853d 100644 --- a/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommandTest.kt +++ b/app/src/test/java/org/libremediaconverter/ffmpeg/FFmpegConcatCommandTest.kt @@ -1,11 +1,11 @@ package org.libremediaconverter.ffmpeg -import org.libremediaconverter.model.ConcatStrategy -import org.libremediaconverter.model.OutputFormat import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.libremediaconverter.model.ConcatStrategy +import org.libremediaconverter.model.OutputFormat import java.io.File class FFmpegConcatCommandTest { @@ -31,7 +31,11 @@ class FFmpegConcatCommandTest { @Test fun `stream copy uses the concat demuxer and copies codecs`() { val args = FFmpegConcatCommand.build( - ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264, + ConcatStrategy.STREAM_COPY, + inputs, + listFile, + output, + OutputFormat.MP4_H264, ) assertTrue(args.contains("concat")) assertEquals("copy", args[args.indexOf("-c") + 1]) @@ -42,7 +46,11 @@ class FFmpegConcatCommandTest { @Test fun `stream copy allows absolute paths in the list file`() { val args = FFmpegConcatCommand.build( - ConcatStrategy.STREAM_COPY, inputs, listFile, output, OutputFormat.MP4_H264, + ConcatStrategy.STREAM_COPY, + inputs, + listFile, + output, + OutputFormat.MP4_H264, ) // Without -safe 0 the demuxer rejects the absolute paths we generate. assertEquals("0", args[args.indexOf("-safe") + 1]) @@ -51,7 +59,11 @@ class FFmpegConcatCommandTest { @Test fun `re-encode passes every input separately and builds a filter graph`() { val args = FFmpegConcatCommand.build( - ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264, + ConcatStrategy.REENCODE, + inputs, + listFile, + output, + OutputFormat.MP4_H264, ) assertEquals(2, args.count { it == "-i" }) val filter = args[args.indexOf("-filter_complex") + 1] @@ -64,7 +76,11 @@ class FFmpegConcatCommandTest { @Test fun `re-encode maps the filter outputs rather than raw streams`() { val args = FFmpegConcatCommand.build( - ConcatStrategy.REENCODE, inputs, listFile, output, OutputFormat.MP4_H264, + ConcatStrategy.REENCODE, + inputs, + listFile, + output, + OutputFormat.MP4_H264, ) assertTrue(args.contains("[v]")) assertTrue(args.contains("[a]")) @@ -81,7 +97,11 @@ class FFmpegConcatCommandTest { @Test fun `mkv output does not get faststart`() { val args = FFmpegConcatCommand.build( - ConcatStrategy.STREAM_COPY, inputs, listFile, File("/cache/j.mkv"), OutputFormat.MKV_H264, + ConcatStrategy.STREAM_COPY, + inputs, + listFile, + File("/cache/j.mkv"), + OutputFormat.MKV_H264, ) assertFalse(args.contains("+faststart")) } diff --git a/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt b/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt index 8dfbb95..f762f14 100644 --- a/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt +++ b/app/src/test/java/org/libremediaconverter/model/ConversionRouterTest.kt @@ -1,8 +1,8 @@ package org.libremediaconverter.model -import org.libremediaconverter.model.ConversionRouter.Reason import org.junit.Assert.assertEquals import org.junit.Test +import org.libremediaconverter.model.ConversionRouter.Reason /** * One test per routing predicate. @@ -291,8 +291,12 @@ class ConversionRouterTest { @Test fun `the new containers are all FFmpeg-only`() { listOf( - Container.MOV, Container.MKV, Container.MPEG_TS, - Container.AVI, Container.FLV, Container.ASF, + Container.MOV, + Container.MKV, + Container.MPEG_TS, + Container.AVI, + Container.FLV, + Container.ASF, ).forEach { container -> val d = route( OutputSpec(container, VideoCodec.COPY, AudioCodec.COPY),