Match the Join tab to the converter's empty state
Applies the same treatment to the Join tab: label and button centred on both axes, button filling the width at 56dp tall, and the same asymmetric screen padding. An empty state that looks different depending on which tab you are on reads as a bug rather than as variety. The two shared dimensions move into ui/Dimens.kt rather than being duplicated per screen, so the tabs cannot drift apart later. Verified on an API 37 emulator: both tabs now present an identical empty state. 65 unit and 17 instrumented tests still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,8 +26,10 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.jasonmross.mediaconverter.ui.PrimaryButtonHeight
|
||||
import dev.jasonmross.mediaconverter.ui.ScreenPaddingHorizontal
|
||||
import dev.jasonmross.mediaconverter.ui.ScreenPaddingVertical
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
@@ -36,17 +38,6 @@ import dev.jasonmross.mediaconverter.model.OutputFormat
|
||||
import dev.jasonmross.mediaconverter.model.QualityTier
|
||||
import java.util.Locale
|
||||
|
||||
/** Primary actions are taller than the Material default so they read as the main affordance. */
|
||||
private val PrimaryButtonHeight: Dp = 56.dp
|
||||
|
||||
/**
|
||||
* Horizontal screen inset.
|
||||
*
|
||||
* Deliberately tighter than the vertical inset so a full-width primary button reaches
|
||||
* close to both edges of the display.
|
||||
*/
|
||||
private val ScreenPadding: Dp = 16.dp
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
fun ConverterScreen(
|
||||
@@ -78,7 +69,7 @@ fun ConverterScreen(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = ScreenPadding, vertical = 24.dp),
|
||||
.padding(horizontal = ScreenPaddingHorizontal, vertical = ScreenPaddingVertical),
|
||||
) {
|
||||
Text(
|
||||
"Media Converter",
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -17,13 +18,18 @@ import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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 dev.jasonmross.mediaconverter.convert.InputFile
|
||||
import dev.jasonmross.mediaconverter.model.ConcatStrategy
|
||||
import dev.jasonmross.mediaconverter.ui.PrimaryButtonHeight
|
||||
import dev.jasonmross.mediaconverter.ui.ScreenPaddingHorizontal
|
||||
import dev.jasonmross.mediaconverter.ui.ScreenPaddingVertical
|
||||
|
||||
@UnstableApi
|
||||
@Composable
|
||||
@@ -44,77 +50,125 @@ fun JoinScreen(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
.padding(horizontal = ScreenPaddingHorizontal, vertical = ScreenPaddingVertical),
|
||||
) {
|
||||
Text("Join files", style = MaterialTheme.typography.headlineMedium)
|
||||
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 -> {
|
||||
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 = { pickInputs.launch(arrayOf("video/*")) }) {
|
||||
Text("Choose files")
|
||||
Button(
|
||||
onClick = { pickInputs.launch(arrayOf("video/*")) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(PrimaryButtonHeight),
|
||||
) { 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 = viewModel::join,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Join ${s.inputs.size} files") }
|
||||
OutlinedButton(
|
||||
onClick = { pickInputs.launch(arrayOf("video/*")) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { 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())
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Cancel") }
|
||||
}
|
||||
|
||||
is JoinState.Waiting -> {
|
||||
Text(
|
||||
"Paused. The system limits background media processing to " +
|
||||
"six hours a day, so this will resume automatically.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = viewModel::cancel,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { 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 = { chooseDestination.launch("joined.mp4") },
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Save file") }
|
||||
OutlinedButton(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
|
||||
is JoinState.Saved -> {
|
||||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Join more") }
|
||||
}
|
||||
|
||||
is JoinState.Failed -> {
|
||||
Text(
|
||||
s.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(
|
||||
onClick = viewModel::reset,
|
||||
modifier = Modifier.fillMaxWidth().height(PrimaryButtonHeight),
|
||||
) { Text("Start over") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is JoinState.Ready -> {
|
||||
s.inputs.forEach { FileRow(it) }
|
||||
Button(onClick = viewModel::join) { Text("Join ${s.inputs.size} files") }
|
||||
OutlinedButton(onClick = { pickInputs.launch(arrayOf("video/*")) }) {
|
||||
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())
|
||||
OutlinedButton(onClick = viewModel::cancel) { Text("Cancel") }
|
||||
}
|
||||
|
||||
is JoinState.Waiting -> {
|
||||
Text(
|
||||
"Paused. The system limits background media processing to six " +
|
||||
"hours a day, so this will resume automatically.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(onClick = viewModel::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 = { chooseDestination.launch("joined.mp4") }) { Text("Save file") }
|
||||
OutlinedButton(onClick = viewModel::reset) { Text("Start over") }
|
||||
}
|
||||
|
||||
is JoinState.Saved -> {
|
||||
Text("Saved ${s.displayName}.", style = MaterialTheme.typography.bodyLarge)
|
||||
Button(onClick = viewModel::reset) { Text("Join more") }
|
||||
}
|
||||
|
||||
is JoinState.Failed -> {
|
||||
Text(
|
||||
s.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Button(onClick = viewModel::reset) { Text("Start over") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.jasonmross.mediaconverter.ui
|
||||
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Layout constants shared by the top-level screens.
|
||||
*
|
||||
* Shared rather than duplicated so the two tabs cannot drift apart: an empty state that
|
||||
* looks different depending on which tab you are on reads as a bug.
|
||||
*/
|
||||
|
||||
/** Primary actions are taller than the Material default so they read as the main affordance. */
|
||||
val PrimaryButtonHeight: Dp = 56.dp
|
||||
|
||||
/**
|
||||
* Horizontal screen inset.
|
||||
*
|
||||
* Deliberately tighter than the vertical inset so a full-width primary button reaches
|
||||
* close to both edges of the display.
|
||||
*/
|
||||
val ScreenPaddingHorizontal: Dp = 16.dp
|
||||
|
||||
val ScreenPaddingVertical: Dp = 24.dp
|
||||
Reference in New Issue
Block a user