The stateful outer composables hand `ConverterScreenContent` and `JoinScreenContent` a list of `viewModel::` references. No test in the suite had ever seen that list: the content tests build their own `ConverterActions`, so they drive the stateless inner and never touch the wiring. THE TICKET'S PREMISE WAS HALF WRONG, AND CHECKING BEAT ASSUMING. #156 was filed claiming a transposition of any two of seventeen bindings would survive the suite. Measured instead of trusted: onVideoCodec <-> onAudioCodec -> REJECTED: "Inapplicable candidate(s): fun setAudioCodec(codec: AudioCodec)" onCancel <-> onReset -> COMPILES Every typed binding -- container, both codecs, preset, suggestion, quality, engine preference -- takes a distinct parameter type, so the compiler is already the test. Writing assertions against those transpositions would have been theatre, and this file says so rather than quietly including them. WHAT IS ACTUALLY AT RISK is the `() -> Unit` bindings, which are interchangeable to the compiler: two on the converter screen (onCancel, onReset) and *three* on the join screen (onJoin, onCancel, onReset). A Cancel button that discards the finished file, a Start-over that leaves it on screen, or a Join button that cancels -- each is one wrong word and each ships. I got that wrong in the first check too: an early run reported the onCancel/ onReset swap as rejected, from a grep-and-exit-code test that misread a stale build. Re-running it properly printed BUILD SUCCESSFUL with the swap in place. THE SEAM. `converterActions(viewModel, onPickInput, onConvert, onSave)` and `joinActions(viewModel, onPickInputs, onSave)`. The launcher-backed actions stay parameters -- they need an ActivityResultLauncher, which is the part that genuinely needs a composition, and keeping them out means the rest needs none. Told apart by effect rather than by a recording double: `reset()` sets the state to Idle, `cancel()` with no active job leaves it alone (`activeWorkId?.let`, which SettingsEditsTest pins). Mutations -- every transposition caught, each by two tests: converter onCancel <-> onReset | 2 tests join onJoin <-> onCancel | 2 tests join onReset <-> onCancel | 2 tests a typed binding dropped to {} | 1 test a launcher action rerouted | 1 test The two-test symmetry is deliberate: one direction alone passes against a wiring with BOTH actions bound to the same method, which is what a copy-pasted line produces. Three guard assertions earned their place during writing -- the picks land through an injected dispatcher, and without `ParkedPickDispatcher.runAll()` all three state-based tests sat on Idle and would have asserted nothing. They failed loudly instead of passing quietly. `@UnstableApi` on both builders, per CLAUDE.md; lint caught their absence, as it did in W1. 525 -> 537 tests, 88.0% -> 88.9% line, 70.5% -> 75.4% branch. Gate green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
714 lines
31 KiB
Kotlin
714 lines
31 KiB
Kotlin
package org.libremediaconverter.convert
|
||
|
||
import android.Manifest
|
||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||
import androidx.activity.result.contract.ActivityResultContracts
|
||
import androidx.compose.animation.AnimatedVisibility
|
||
import androidx.compose.foundation.layout.Arrangement
|
||
import androidx.compose.foundation.layout.Column
|
||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||
import androidx.compose.foundation.layout.FlowRow
|
||
import androidx.compose.foundation.layout.fillMaxSize
|
||
import androidx.compose.foundation.layout.fillMaxWidth
|
||
import androidx.compose.foundation.layout.height
|
||
import androidx.compose.foundation.layout.padding
|
||
import androidx.compose.foundation.rememberScrollState
|
||
import androidx.compose.foundation.verticalScroll
|
||
import androidx.compose.material3.AssistChip
|
||
import androidx.compose.material3.Button
|
||
import androidx.compose.material3.Card
|
||
import androidx.compose.material3.CardDefaults
|
||
import androidx.compose.material3.FilterChip
|
||
import androidx.compose.material3.HorizontalDivider
|
||
import androidx.compose.material3.LinearProgressIndicator
|
||
import androidx.compose.material3.MaterialTheme
|
||
import androidx.compose.material3.OutlinedButton
|
||
import androidx.compose.material3.Text
|
||
import androidx.compose.material3.TextButton
|
||
import androidx.compose.runtime.Composable
|
||
import androidx.compose.runtime.getValue
|
||
import androidx.compose.runtime.mutableStateOf
|
||
import androidx.compose.runtime.remember
|
||
import androidx.compose.runtime.saveable.rememberSaveable
|
||
import androidx.compose.runtime.setValue
|
||
import androidx.compose.ui.Alignment
|
||
import androidx.compose.ui.Modifier
|
||
import androidx.compose.ui.platform.testTag
|
||
import androidx.compose.ui.text.style.TextAlign
|
||
import androidx.compose.ui.unit.dp
|
||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||
import androidx.media3.common.util.UnstableApi
|
||
import org.libremediaconverter.model.AudioCodec
|
||
import org.libremediaconverter.model.CodecNames
|
||
import org.libremediaconverter.model.Container
|
||
import org.libremediaconverter.model.EnginePreference
|
||
import org.libremediaconverter.model.InputKind
|
||
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.ui.PrimaryButtonHeight
|
||
import org.libremediaconverter.ui.ScreenPaddingHorizontal
|
||
import org.libremediaconverter.ui.ScreenPaddingVertical
|
||
import org.libremediaconverter.ui.TestTags
|
||
import java.util.Locale
|
||
|
||
@UnstableApi
|
||
@Composable
|
||
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()
|
||
|
||
// ACTION_OPEN_DOCUMENT rather than the photo picker: the picker is images and video
|
||
// only, offers no audio at all, and will not reliably surface .mkv/.flac/.webm.
|
||
// SAF needs no runtime permission.
|
||
val pickInput = rememberLauncherForActivityResult(
|
||
ActivityResultContracts.OpenDocument(),
|
||
) { uri -> uri?.let(viewModel::onInputPicked) }
|
||
|
||
// The contract's MIME type comes from the finished job rather than from the picker as it
|
||
// stands: some providers rewrite a document's extension to match it, so an MP3 offered as
|
||
// video/webm can arrive with the wrong one. Read straight off the collected state, so this
|
||
// recomposes because it depends on that rather than because an unrelated line happens to.
|
||
// Through pendingSave() rather than a cast to Converted, so a retry offered after a failed
|
||
// save opens the dialog with the type its first attempt used -- the cast answered null for a
|
||
// Failed, and the fallback below is the current picker, which a reattached job never set.
|
||
// Remembered against the type so the launcher re-registers only when it actually changes.
|
||
val destinationMime = state.pendingSave()?.mimeType ?: settings.spec.mimeType
|
||
val chooseDestination = rememberLauncherForActivityResult(
|
||
remember(destinationMime) { ActivityResultContracts.CreateDocument(destinationMime) },
|
||
) { uri -> uri?.let(viewModel::save) }
|
||
|
||
// Requested at the point of use rather than on first launch, so the ask carries its
|
||
// own justification. The conversion starts either way: without the permission the
|
||
// foreground service still runs, but its progress notification is confined to the
|
||
// Task Manager instead of the shade.
|
||
val requestNotifications = rememberLauncherForActivityResult(
|
||
ActivityResultContracts.RequestPermission(),
|
||
) { viewModel.convert() }
|
||
|
||
ConverterScreenContent(
|
||
state = state,
|
||
settings = settings,
|
||
validation = validation,
|
||
actions = converterActions(
|
||
viewModel = viewModel,
|
||
onPickInput = { pickInput.launch(arrayOf("*/*")) },
|
||
onConvert = { requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) },
|
||
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
|
||
),
|
||
modifier = modifier,
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Which of the ViewModel's methods each affordance on the screen calls.
|
||
*
|
||
* ## Why this is a function rather than an argument list
|
||
*
|
||
* It was an argument list, inside [ConverterScreen], which no test reached: `ConverterScreenContent`
|
||
* builds its own [ConverterActions], so every test in the suite drove the stateless inner and none
|
||
* of them ever saw the wiring.
|
||
*
|
||
* Most of the list is safe without a test, and saying so is more useful than pretending otherwise:
|
||
* `onContainer`, `onVideoCodec`, `onAudioCodec`, `onPreset`, `onSuggestion`, `onQuality` and
|
||
* `onEnginePreference` each take a distinct type, so binding one to another's setter does not
|
||
* compile. Verified rather than assumed — swapping `onVideoCodec` and `onAudioCodec` fails with
|
||
* *"Inapplicable candidate(s): fun setAudioCodec(codec: AudioCodec)"*.
|
||
*
|
||
* **[ConverterActions.onCancel] and [ConverterActions.onReset] are the exception.** Both are
|
||
* `() -> Unit`, so swapping them compiles silently — also verified — and ships a Cancel button that
|
||
* throws the conversion away and a Start-over button that leaves it on screen. That pair is what
|
||
* `ConverterWiringTest` exists for.
|
||
*
|
||
* The three launcher-backed actions stay parameters: they need an `ActivityResultLauncher`, which
|
||
* is the part that genuinely needs the composition, and keeping them out means the rest can be
|
||
* checked without one.
|
||
*/
|
||
@UnstableApi
|
||
internal fun converterActions(
|
||
viewModel: ConversionViewModel,
|
||
onPickInput: () -> Unit,
|
||
onConvert: () -> Unit,
|
||
onSave: (suggestedName: String) -> Unit,
|
||
): ConverterActions = ConverterActions(
|
||
onPickInput = onPickInput,
|
||
onPreset = viewModel::setPreset,
|
||
onContainer = viewModel::setContainer,
|
||
onVideoCodec = viewModel::setVideoCodec,
|
||
onAudioCodec = viewModel::setAudioCodec,
|
||
onSuggestion = viewModel::applySuggestion,
|
||
onQuality = viewModel::setQuality,
|
||
onEnginePreference = viewModel::setEnginePreference,
|
||
onConvert = onConvert,
|
||
onCancel = viewModel::cancel,
|
||
onSave = onSave,
|
||
onReset = viewModel::reset,
|
||
)
|
||
|
||
/**
|
||
* Everything [ConverterScreenContent] can ask for, in one value.
|
||
*
|
||
* A holder rather than twelve parameters because detekt's `LongParameterList` sits at its default
|
||
* threshold of six and `config/detekt/detekt.yml` does not relax it for `@Composable` the way it
|
||
* relaxes `LongMethod` and `CyclomaticComplexMethod` -- `AdvancedPicker` already sits exactly on
|
||
* that threshold. The rule exempts data classes, so the callbacks travel together.
|
||
*
|
||
* In production every one of these is a launcher or a `ConversionViewModel` call. Naming them here
|
||
* instead of handing the content a ViewModel is the whole point of the seam: a test can render a
|
||
* [ConversionState] no ViewModel can be driven into, since `Waiting` needs a denied foreground
|
||
* start and `Converted` needs a worker run that has already succeeded.
|
||
*/
|
||
internal data class ConverterActions(
|
||
/** Open the document picker. The `Idle` and `Ready` branches both offer it. */
|
||
val onPickInput: () -> Unit,
|
||
val onPreset: (OutputFormat) -> Unit,
|
||
val onContainer: (Container) -> Unit,
|
||
val onVideoCodec: (VideoCodec) -> Unit,
|
||
val onAudioCodec: (AudioCodec) -> Unit,
|
||
val onSuggestion: (OutputSpec) -> Unit,
|
||
val onQuality: (QualityTier) -> Unit,
|
||
val onEnginePreference: (EnginePreference) -> Unit,
|
||
/**
|
||
* Start the job. It asks for the notification permission first, which is why the screen never
|
||
* calls `convert` directly -- the launcher's result callback does, whichever way it went.
|
||
*/
|
||
val onConvert: () -> Unit,
|
||
val onCancel: () -> Unit,
|
||
/**
|
||
* Open the save dialog for the finished output.
|
||
*
|
||
* Takes the suggested name rather than reading it back off the state, because the name comes
|
||
* from the job -- see `ConversionWorker.KEY_SUGGESTED_NAME` -- and the branch that renders the
|
||
* button is the only place that has it.
|
||
*/
|
||
val onSave: (suggestedName: String) -> Unit,
|
||
val onReset: () -> Unit,
|
||
)
|
||
|
||
/**
|
||
* The converter screen, with its state handed in.
|
||
*
|
||
* Split from [ConverterScreen] so that state has somewhere to come from other than a live
|
||
* `ConversionViewModel`. Driving the screen through a real one needs a `WorkManager` and a media
|
||
* probe in the constructor, and even then two of the six states are unreachable: `Waiting` follows
|
||
* a denied foreground start and `Converted` follows a completed worker.
|
||
*
|
||
* `internal` rather than private, because `src/test` is a friend of `main` and this is what the
|
||
* state tests compose. The leaves below stay exactly where they were -- this function is a move,
|
||
* not a redesign, and the tests that already pin those leaves are what says so.
|
||
*/
|
||
@UnstableApi
|
||
@Composable
|
||
internal fun ConverterScreenContent(
|
||
state: ConversionState,
|
||
settings: ConversionSettings,
|
||
validation: Validation,
|
||
actions: ConverterActions,
|
||
modifier: Modifier = Modifier,
|
||
) {
|
||
Column(
|
||
modifier = modifier
|
||
.fillMaxSize()
|
||
.padding(horizontal = ScreenPaddingHorizontal, vertical = ScreenPaddingVertical),
|
||
) {
|
||
Text(
|
||
"LibreMediaConverter",
|
||
style = MaterialTheme.typography.headlineMedium,
|
||
modifier = Modifier.padding(bottom = 16.dp),
|
||
)
|
||
|
||
// The empty state is centred in whatever space is left. The working states
|
||
// scroll instead, since their content can exceed the screen.
|
||
val body = Modifier.fillMaxWidth().weight(1f)
|
||
|
||
when (val s = state) {
|
||
is ConversionState.Idle -> Column(
|
||
modifier = body,
|
||
verticalArrangement = Arrangement.Center,
|
||
horizontalAlignment = Alignment.CenterHorizontally,
|
||
) {
|
||
Text(
|
||
"Pick a file to convert.",
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
textAlign = TextAlign.Center,
|
||
modifier = Modifier.padding(bottom = 16.dp),
|
||
)
|
||
Button(
|
||
onClick = actions.onPickInput,
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.height(PrimaryButtonHeight)
|
||
.testTag(TestTags.Converter.CHOOSE_FILE),
|
||
) { Text("Choose file") }
|
||
}
|
||
|
||
else -> Column(
|
||
modifier = body.verticalScroll(rememberScrollState()),
|
||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||
) {
|
||
when (s) {
|
||
is ConversionState.Idle -> Unit
|
||
|
||
is ConversionState.Ready -> {
|
||
FileCard(s.input)
|
||
FormatPicker(settings.matchingPreset, actions.onPreset)
|
||
AdvancedPicker(
|
||
spec = settings.spec,
|
||
validation = validation,
|
||
onContainer = actions.onContainer,
|
||
onVideoCodec = actions.onVideoCodec,
|
||
onAudioCodec = actions.onAudioCodec,
|
||
onSuggestion = actions.onSuggestion,
|
||
)
|
||
QualityPicker(settings.quality, actions.onQuality)
|
||
EnginePicker(settings.enginePreference, actions.onEnginePreference)
|
||
Button(
|
||
onClick = actions.onConvert,
|
||
// The Advanced picker lets an impossible combination be selected on
|
||
// purpose, so this is what stops it from being run.
|
||
enabled = validation.isValid,
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.height(PrimaryButtonHeight)
|
||
.testTag(TestTags.Converter.CONVERT),
|
||
) { Text("Convert") }
|
||
OutlinedButton(
|
||
onClick = actions.onPickInput,
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.testTag(TestTags.Converter.CHOOSE_DIFFERENT_FILE),
|
||
) { Text("Choose a different file") }
|
||
}
|
||
|
||
is ConversionState.Converting -> {
|
||
FileCard(s.input)
|
||
Text("Converting… ${s.percent}%")
|
||
LinearProgressIndicator(
|
||
progress = { s.percent / 100f },
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.testTag(TestTags.Converter.PROGRESS),
|
||
)
|
||
OutlinedButton(
|
||
onClick = actions.onCancel,
|
||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||
) { Text("Cancel") }
|
||
}
|
||
|
||
is ConversionState.Waiting -> {
|
||
FileCard(s.input)
|
||
// Two different causes land here and the state cannot tell them apart:
|
||
// the six-hour-a-day background media budget running out, and the system
|
||
// refusing to let a job restart while the app is in the background. The
|
||
// old wording named only the first, which is now the less likely of the
|
||
// two. "Keeping the app open helps" covers both -- it is literally what
|
||
// grants the second one permission to run.
|
||
Text(
|
||
"Paused. Android limits background media processing, so this will " +
|
||
"resume automatically — keeping the app open helps it along.",
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
)
|
||
OutlinedButton(
|
||
onClick = actions.onCancel,
|
||
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
||
) { Text("Cancel") }
|
||
}
|
||
|
||
is ConversionState.Converted -> {
|
||
FileCard(s.input)
|
||
Text(
|
||
"Done — ${formatBytes(s.staged.length())} output.",
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
)
|
||
if (s.routeReason.isNotBlank()) {
|
||
// Surfacing the routing decision rather than hiding it: it
|
||
// explains why a job was slow, makes the software fallback
|
||
// visible, and is how the user learns a remux happened rather
|
||
// than a re-encode.
|
||
AssistChip(
|
||
onClick = {},
|
||
label = { Text(s.routeReason) },
|
||
modifier = Modifier.testTag(TestTags.Converter.ROUTE_REASON),
|
||
)
|
||
}
|
||
Button(
|
||
onClick = { actions.onSave(s.suggestedName) },
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.height(PrimaryButtonHeight)
|
||
.testTag(TestTags.SAVE_FILE),
|
||
) { Text("Save file") }
|
||
OutlinedButton(
|
||
onClick = actions.onReset,
|
||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||
) { Text("Start over") }
|
||
}
|
||
|
||
is ConversionState.Saved -> {
|
||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||
Button(
|
||
onClick = actions.onReset,
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.height(PrimaryButtonHeight)
|
||
.testTag(TestTags.Converter.CONVERT_ANOTHER),
|
||
) { Text("Convert another") }
|
||
}
|
||
|
||
is ConversionState.Failed -> {
|
||
Text(
|
||
s.message,
|
||
color = MaterialTheme.colorScheme.error,
|
||
style = MaterialTheme.typography.bodyMedium,
|
||
)
|
||
val retry = s.retry
|
||
if (retry == null) {
|
||
// Nothing was staged, so "Start over" is the whole of what is on
|
||
// offer and stays the primary button.
|
||
Button(
|
||
onClick = actions.onReset,
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.height(PrimaryButtonHeight)
|
||
.testTag(TestTags.START_OVER),
|
||
) { Text("Start over") }
|
||
} else {
|
||
Button(
|
||
onClick = { actions.onSave(retry.suggestedName) },
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.height(PrimaryButtonHeight)
|
||
.testTag(TestTags.RETRY_SAVE),
|
||
) { Text("Try saving again") }
|
||
// Start over still deletes the file this state is carrying, and that
|
||
// is deliberate: `reset()` is what stops a full-size output sitting in
|
||
// cache until the sweep. What makes the delete acceptable is the
|
||
// button above it. Deletion is the user's choice only once the
|
||
// alternative has been offered -- and until that button existed, this
|
||
// one was the only thing a failed save could lead to.
|
||
OutlinedButton(
|
||
onClick = actions.onReset,
|
||
modifier = Modifier.fillMaxWidth().testTag(TestTags.START_OVER),
|
||
) { Text("Start over") }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@OptIn(ExperimentalLayoutApi::class)
|
||
@Composable
|
||
internal fun FormatPicker(selected: OutputFormat?, onSelect: (OutputFormat) -> Unit) {
|
||
Text("Output format", style = MaterialTheme.typography.titleSmall)
|
||
FlowRow(
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||
modifier = Modifier.testTag(TestTags.Converter.FORMAT_CHIPS),
|
||
) {
|
||
OutputFormat.entries.forEach { format ->
|
||
FilterChip(
|
||
selected = format == selected,
|
||
onClick = { onSelect(format) },
|
||
label = { Text(format.label) },
|
||
)
|
||
}
|
||
}
|
||
if (selected == null) {
|
||
Text(
|
||
"Custom — set below.",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||
)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The full container × codec matrix.
|
||
*
|
||
* Every combination stays selectable, including the ones that cannot work. Disabling or hiding
|
||
* them would leave the user guessing why the option they wanted is not there; letting them pick it
|
||
* and then saying what is wrong — and what would work instead — teaches the constraint. The
|
||
* Convert button is what actually blocks the job.
|
||
*/
|
||
@OptIn(ExperimentalLayoutApi::class)
|
||
@Composable
|
||
internal fun AdvancedPicker(
|
||
spec: OutputSpec,
|
||
validation: Validation,
|
||
onContainer: (Container) -> Unit,
|
||
onVideoCodec: (VideoCodec) -> Unit,
|
||
onAudioCodec: (AudioCodec) -> Unit,
|
||
onSuggestion: (OutputSpec) -> Unit,
|
||
) {
|
||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||
|
||
TextButton(
|
||
onClick = { expanded = !expanded },
|
||
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_TOGGLE),
|
||
) {
|
||
Text(if (expanded) "Hide advanced" else "Advanced")
|
||
}
|
||
|
||
AnimatedVisibility(visible = expanded) {
|
||
Column(
|
||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_PANEL),
|
||
) {
|
||
Text("Container", style = MaterialTheme.typography.titleSmall)
|
||
FlowRow(
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_CONTAINER_CHIPS),
|
||
) {
|
||
Container.entries.forEach { container ->
|
||
FilterChip(
|
||
selected = container == spec.container,
|
||
onClick = { onContainer(container) },
|
||
label = { Text(container.label) },
|
||
)
|
||
}
|
||
}
|
||
|
||
Text("Video", style = MaterialTheme.typography.titleSmall)
|
||
FlowRow(
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_VIDEO_CHIPS),
|
||
) {
|
||
VideoCodec.entries.forEach { codec ->
|
||
FilterChip(
|
||
selected = codec == spec.videoCodec,
|
||
onClick = { onVideoCodec(codec) },
|
||
label = { Text(codec.label) },
|
||
)
|
||
}
|
||
}
|
||
|
||
Text("Audio", style = MaterialTheme.typography.titleSmall)
|
||
FlowRow(
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||
modifier = Modifier.testTag(TestTags.Converter.ADVANCED_AUDIO_CHIPS),
|
||
) {
|
||
AudioCodec.entries.forEach { codec ->
|
||
FilterChip(
|
||
selected = codec == spec.audioCodec,
|
||
onClick = { onAudioCodec(codec) },
|
||
label = { Text(codec.label) },
|
||
)
|
||
}
|
||
}
|
||
|
||
Text(
|
||
"Copy keeps the original stream — no re-encoding, so it finishes in seconds.",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||
)
|
||
}
|
||
}
|
||
|
||
if (validation is Validation.Invalid) {
|
||
ValidationError(validation, onSuggestion)
|
||
}
|
||
}
|
||
|
||
@OptIn(ExperimentalLayoutApi::class)
|
||
@Composable
|
||
internal fun ValidationError(invalid: Validation.Invalid, onSuggestion: (OutputSpec) -> Unit) {
|
||
Card(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.testTag(TestTags.Converter.VALIDATION_ERROR),
|
||
colors = CardDefaults.cardColors(
|
||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||
),
|
||
) {
|
||
Column(
|
||
modifier = Modifier.padding(16.dp),
|
||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||
) {
|
||
Text(invalid.message, style = MaterialTheme.typography.bodyMedium)
|
||
if (invalid.suggestions.isNotEmpty()) {
|
||
Text("Try instead:", style = MaterialTheme.typography.labelMedium)
|
||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||
invalid.suggestions.forEachIndexed { index, suggestion ->
|
||
AssistChip(
|
||
onClick = { onSuggestion(suggestion) },
|
||
label = { Text(describe(suggestion)) },
|
||
modifier = Modifier.testTag(TestTags.Converter.suggestion(index)),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
internal fun describe(spec: OutputSpec): String {
|
||
val video = when (spec.videoCodec) {
|
||
VideoCodec.NONE -> null
|
||
else -> spec.videoCodec.label
|
||
}
|
||
val audio = when (spec.audioCodec) {
|
||
AudioCodec.NONE -> null
|
||
else -> spec.audioCodec.label
|
||
}
|
||
val tracks = listOfNotNull(video, audio).joinToString(" + ")
|
||
return if (tracks.isEmpty()) spec.container.label else "${spec.container.label} · $tracks"
|
||
}
|
||
|
||
@OptIn(ExperimentalLayoutApi::class)
|
||
@Composable
|
||
internal fun QualityPicker(selected: QualityTier, onSelect: (QualityTier) -> Unit) {
|
||
Text("Quality", style = MaterialTheme.typography.titleSmall)
|
||
FlowRow(
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||
modifier = Modifier.testTag(TestTags.Converter.QUALITY_CHIPS),
|
||
) {
|
||
QualityTier.entries.forEach { tier ->
|
||
FilterChip(
|
||
selected = tier == selected,
|
||
onClick = { onSelect(tier) },
|
||
label = { Text(tier.label) },
|
||
)
|
||
}
|
||
}
|
||
Text(selected.description, style = MaterialTheme.typography.bodySmall)
|
||
}
|
||
|
||
@OptIn(ExperimentalLayoutApi::class)
|
||
@Composable
|
||
internal fun EnginePicker(selected: EnginePreference, onSelect: (EnginePreference) -> Unit) {
|
||
Text("Engine", style = MaterialTheme.typography.titleSmall)
|
||
FlowRow(
|
||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||
modifier = Modifier.testTag(TestTags.Converter.ENGINE_CHIPS),
|
||
) {
|
||
EnginePreference.entries.forEach { preference ->
|
||
FilterChip(
|
||
selected = preference == selected,
|
||
onClick = { onSelect(preference) },
|
||
label = { Text(preference.label()) },
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
internal fun EnginePreference.label(): String = when (this) {
|
||
EnginePreference.AUTO -> "Automatic"
|
||
EnginePreference.PREFER_HARDWARE -> "Prefer hardware"
|
||
EnginePreference.FORCE_SOFTWARE -> "Force software"
|
||
}
|
||
|
||
/**
|
||
* Name, size, and what the file actually turned out to be.
|
||
*
|
||
* The codec lines are what make "Copy" a meaningful choice — without knowing the source is H.264,
|
||
* "copy the video" is a guess. They degrade explicitly rather than silently: an audio file says so
|
||
* instead of showing a blank video row, and a file nothing could read says that rather than
|
||
* pretending it has an unknown codec.
|
||
*/
|
||
@Composable
|
||
internal fun FileCard(input: InputFile) {
|
||
Card(
|
||
modifier = Modifier
|
||
.fillMaxWidth()
|
||
.testTag(TestTags.Converter.FILE_CARD),
|
||
) {
|
||
Column(modifier = Modifier.padding(16.dp)) {
|
||
Text(
|
||
input.displayName,
|
||
style = MaterialTheme.typography.titleMedium,
|
||
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NAME),
|
||
)
|
||
// The null is handled here rather than inside formatBytes, because "no provider would
|
||
// say" is not a number and a formatter that invented one -- "0 B" -- is the defect
|
||
// this card would be showing. It degrades in words, like the codec rows below it.
|
||
Text(
|
||
input.sizeBytes?.let(::formatBytes) ?: "Size unknown",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_BYTES),
|
||
)
|
||
|
||
val probe = input.probe
|
||
if (probe == null) {
|
||
Text(
|
||
"Reading…",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NOTE),
|
||
)
|
||
return@Column
|
||
}
|
||
|
||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||
|
||
when (probe.kind) {
|
||
InputKind.UNPARSEABLE -> Text(
|
||
"Could not identify this file. It will be converted with FFmpeg.",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
modifier = Modifier.testTag(TestTags.Converter.FILE_CARD_NOTE),
|
||
)
|
||
|
||
InputKind.IMAGE -> {
|
||
DetailRow("Type", "Image")
|
||
if (probe.width > 0) DetailRow("Size", "${probe.width}×${probe.height}")
|
||
}
|
||
|
||
InputKind.AUDIO_ONLY -> {
|
||
DetailRow("Container", probe.container?.label ?: "Unknown")
|
||
DetailRow("Video", "No video track")
|
||
DetailRow("Audio", CodecNames.describeAudio(probe.audioCodec))
|
||
if (probe.durationMs > 0) DetailRow("Length", formatDuration(probe.durationMs))
|
||
}
|
||
|
||
InputKind.VIDEO -> {
|
||
DetailRow("Container", probe.container?.label ?: "Unknown")
|
||
DetailRow(
|
||
"Video",
|
||
buildString {
|
||
append(CodecNames.describeVideo(probe.videoCodec))
|
||
if (probe.width > 0) append(" · ${probe.width}×${probe.height}")
|
||
},
|
||
)
|
||
DetailRow(
|
||
"Audio",
|
||
if (probe.audioCodec == null) {
|
||
"No audio track"
|
||
} else {
|
||
CodecNames.describeAudio(probe.audioCodec)
|
||
},
|
||
)
|
||
if (probe.durationMs > 0) DetailRow("Length", formatDuration(probe.durationMs))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
@Composable
|
||
internal fun DetailRow(label: String, value: String) {
|
||
Text(
|
||
"$label: $value",
|
||
style = MaterialTheme.typography.bodySmall,
|
||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||
modifier = Modifier.testTag(TestTags.Converter.detailRow(label)),
|
||
)
|
||
}
|
||
|
||
internal fun formatDuration(ms: Long): String {
|
||
val totalSeconds = ms / 1000
|
||
val minutes = totalSeconds / 60
|
||
val seconds = totalSeconds % 60
|
||
return String.format(Locale.US, "%d:%02d", minutes, seconds)
|
||
}
|
||
|
||
internal fun formatBytes(bytes: Long): String = when {
|
||
bytes >= 1_000_000_000 -> String.format(Locale.US, "%.1f GB", bytes / 1e9)
|
||
bytes >= 1_000_000 -> String.format(Locale.US, "%.1f MB", bytes / 1e6)
|
||
bytes >= 1_000 -> String.format(Locale.US, "%.0f kB", bytes / 1e3)
|
||
else -> "$bytes B"
|
||
}
|