`ConverterScreen` and `JoinScreen` each inlined their whole `when (state)` inside the public entry point, and state arrived only as `viewModel.state`. That left four of the twelve state branches across the two screens with no test that could ever reach them: driving a real ViewModel needs a WorkManager and a media probe in the constructor, and even then `Waiting` follows a denied foreground start and `Converted`/`Joined` follow a worker run that has already succeeded. So the `when` moves into `ConverterScreenContent` and `JoinScreenContent`, which take the state, the settings, the validation and an actions holder. The entry points keep the three launchers and `collectAsStateWithLifecycle` and nothing else. The callbacks travel in `ConverterActions` / `JoinActions` rather than as loose parameters because detekt's `LongParameterList` sits at its default threshold of six and `config/detekt/detekt.yml` does not relax it for `@Composable` -- `AdvancedPicker` already sits exactly on it. Twelve flat parameters would turn a clean detekt run red; data classes are exempt from the rule. Nothing else changed. The body was cut and pasted rather than retyped, so the U+2026, U+2014 and U+00B7 characters the leaf tests match on are the same bytes, and `is Idle -> Unit` in the nested `when` -- permanently unreachable, and deliberately kept -- survives the move. The diff stops above `FormatPicker` in one file and above `FileRow` in the other, which is why the leaf suites #57-#60 landed pass unedited: every one of them composes a leaf directly and none references either entry point. The two new tests are the bite, one per screen and one per direction of the seam: a `Converted` / `Joined` state renders Save, and tapping Save hands back the name the finished job chose. The state matrix itself is #62 and #63.
255 lines
11 KiB
Kotlin
255 lines
11 KiB
Kotlin
package org.libremediaconverter.join
|
|
|
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Column
|
|
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.Button
|
|
import androidx.compose.material3.Card
|
|
import androidx.compose.material3.LinearProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedButton
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.remember
|
|
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.convert.InputFile
|
|
import org.libremediaconverter.model.ConcatStrategy
|
|
import org.libremediaconverter.ui.PrimaryButtonHeight
|
|
import org.libremediaconverter.ui.ScreenPaddingHorizontal
|
|
import org.libremediaconverter.ui.ScreenPaddingVertical
|
|
import org.libremediaconverter.ui.TestTags
|
|
import org.libremediaconverter.work.ConcatWorker
|
|
|
|
@UnstableApi
|
|
@Composable
|
|
fun JoinScreen(modifier: Modifier = Modifier, viewModel: JoinViewModel = viewModel()) {
|
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
|
|
val pickInputs = rememberLauncherForActivityResult(
|
|
ActivityResultContracts.OpenMultipleDocuments(),
|
|
) { uris -> if (uris.isNotEmpty()) viewModel.onInputsPicked(uris) }
|
|
|
|
// The contract's MIME type comes from the finished job rather than from a literal: some
|
|
// providers rewrite a document's extension to match it, so naming MP4 for a join that is not
|
|
// one can hand the user a file the extension lies about. Remembered against that type so the
|
|
// launcher re-registers only when it actually changes.
|
|
val destinationMime = (state as? JoinState.Joined)?.mimeType ?: ConcatWorker.DEFAULT_FORMAT.mimeType
|
|
val chooseDestination = rememberLauncherForActivityResult(
|
|
remember(destinationMime) { ActivityResultContracts.CreateDocument(destinationMime) },
|
|
) { uri -> uri?.let(viewModel::save) }
|
|
|
|
JoinScreenContent(
|
|
state = state,
|
|
actions = JoinActions(
|
|
onPickInputs = { pickInputs.launch(arrayOf("video/*")) },
|
|
onJoin = viewModel::join,
|
|
onCancel = viewModel::cancel,
|
|
onSave = { suggestedName -> chooseDestination.launch(suggestedName) },
|
|
onReset = viewModel::reset,
|
|
),
|
|
modifier = modifier,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Everything [JoinScreenContent] can ask for, in one value.
|
|
*
|
|
* Five callbacks would fit under detekt's `LongParameterList` threshold, unlike the converter's
|
|
* twelve. It is a holder anyway, so both screens present the same shape to the state tests and
|
|
* neither one has to be reworked the first time a branch grows a button.
|
|
*/
|
|
internal data class JoinActions(
|
|
/** Open the multi-document picker. The `Idle` and `Ready` branches both offer it. */
|
|
val onPickInputs: () -> Unit,
|
|
val onJoin: () -> Unit,
|
|
val onCancel: () -> Unit,
|
|
/** Open the save dialog. Takes the name the job chose -- see `ConcatWorker.KEY_SUGGESTED_NAME`. */
|
|
val onSave: (suggestedName: String) -> Unit,
|
|
val onReset: () -> Unit,
|
|
)
|
|
|
|
/**
|
|
* The join screen, with its state handed in.
|
|
*
|
|
* The same split as [org.libremediaconverter.convert.ConverterScreenContent], for the same reason:
|
|
* `JoinState.Waiting` follows a denied foreground start and `JoinState.Joined` follows a completed
|
|
* concatenation, so neither is reachable by driving a real `JoinViewModel`.
|
|
*/
|
|
@UnstableApi
|
|
@Composable
|
|
internal fun JoinScreenContent(state: JoinState, actions: JoinActions, modifier: Modifier = Modifier) {
|
|
Column(
|
|
modifier = modifier
|
|
.fillMaxSize()
|
|
.padding(horizontal = ScreenPaddingHorizontal, vertical = ScreenPaddingVertical),
|
|
) {
|
|
Text(
|
|
"Join files",
|
|
style = MaterialTheme.typography.headlineMedium,
|
|
modifier = Modifier.padding(bottom = 16.dp),
|
|
)
|
|
|
|
// Same split as the converter screen: the empty state is centred, the working
|
|
// states scroll because their content can exceed the screen.
|
|
val body = Modifier.fillMaxWidth().weight(1f)
|
|
|
|
when (val s = state) {
|
|
is JoinState.Idle -> Column(
|
|
modifier = body,
|
|
verticalArrangement = Arrangement.Center,
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
) {
|
|
Text(
|
|
"Pick two or more files to join, in the order you want them.",
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
textAlign = TextAlign.Center,
|
|
modifier = Modifier.padding(bottom = 16.dp),
|
|
)
|
|
Button(
|
|
onClick = actions.onPickInputs,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(PrimaryButtonHeight)
|
|
.testTag(TestTags.Join.CHOOSE_FILES),
|
|
) { Text("Choose files") }
|
|
}
|
|
|
|
else -> Column(
|
|
modifier = body.verticalScroll(rememberScrollState()),
|
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
) {
|
|
when (s) {
|
|
is JoinState.Idle -> Unit
|
|
|
|
is JoinState.Ready -> {
|
|
s.inputs.forEach { FileRow(it) }
|
|
Button(
|
|
onClick = actions.onJoin,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(PrimaryButtonHeight)
|
|
.testTag(TestTags.Join.JOIN),
|
|
) { Text("Join ${s.inputs.size} files") }
|
|
OutlinedButton(
|
|
onClick = actions.onPickInputs,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.testTag(TestTags.Join.CHOOSE_DIFFERENT_FILES),
|
|
) { Text("Choose different files") }
|
|
}
|
|
|
|
is JoinState.Joining -> {
|
|
Text("Joining ${s.inputs.size} files…")
|
|
// Indeterminate on purpose: FFmpeg reports progress against a
|
|
// single input's duration, which means nothing across a
|
|
// concatenation. A fabricated percentage would be worse than none.
|
|
LinearProgressIndicator(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.testTag(TestTags.Join.PROGRESS),
|
|
)
|
|
OutlinedButton(
|
|
onClick = actions.onCancel,
|
|
modifier = Modifier.fillMaxWidth().testTag(TestTags.CANCEL),
|
|
) { Text("Cancel") }
|
|
}
|
|
|
|
is JoinState.Waiting -> {
|
|
// Same two causes as the converter screen's Waiting state, and the same
|
|
// wording for them -- see the comment there.
|
|
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 JoinState.Joined -> {
|
|
Text("Joined — ${s.staged.length() / 1_000_000} MB.")
|
|
Text(
|
|
when (s.strategy) {
|
|
ConcatStrategy.STREAM_COPY ->
|
|
"Files matched, so they were joined without " +
|
|
"re-encoding — no quality loss."
|
|
ConcatStrategy.REENCODE ->
|
|
"Files differed in format, so they were re-encoded " +
|
|
"to match."
|
|
},
|
|
style = MaterialTheme.typography.bodySmall,
|
|
)
|
|
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 JoinState.Saved -> {
|
|
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
|
Button(
|
|
onClick = actions.onReset,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(PrimaryButtonHeight)
|
|
.testTag(TestTags.Join.JOIN_MORE),
|
|
) { Text("Join more") }
|
|
}
|
|
|
|
is JoinState.Failed -> {
|
|
Text(
|
|
s.message,
|
|
color = MaterialTheme.colorScheme.error,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
Button(
|
|
onClick = actions.onReset,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.height(PrimaryButtonHeight)
|
|
.testTag(TestTags.START_OVER),
|
|
) { Text("Start over") }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
internal fun FileRow(input: InputFile) {
|
|
Card(
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.testTag(TestTags.Join.fileRow(input.displayName)),
|
|
) {
|
|
Column(modifier = Modifier.padding(12.dp)) {
|
|
Text(input.displayName, style = MaterialTheme.typography.bodyMedium)
|
|
}
|
|
}
|
|
}
|