Files
LibreMediaConverter/app/src/main/java/org/libremediaconverter/convert/ConverterScreen.kt
T
JMR-devandClaude Opus 5 6f3966cc69 W3 (#156): the screen wiring, and a narrower hazard than the ticket claimed
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>
2026-08-29 10:56:44 -05:00

714 lines
31 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
}