commit 11d2e057f70a69395751bcd93236085060b47144 equwal <truex@equwal.com> 2026-09-21 01:55:21 -0700 Show all that the app can do on the first screen A job takes hours, and the video part showed only after a job. A new user could not see what the app makes. The outputs are now on the first screen as real controls: subtitles, and video + subtitles with its size and frame rate. The options work at once. The save buttons are grey until a job is done. A short list says what no control can say: the audio and book formats, the languages, and that all work stays on the device.
.../kotlin/space/subread/app/CapabilitiesTest.kt | 51 ++++++++ .../main/kotlin/space/subread/app/MainActivity.kt | 136 +++++++++++++-------- 2 files changed, 139 insertions(+), 48 deletions(-)
diff --git a/app/src/androidTest/kotlin/space/subread/app/CapabilitiesTest.kt b/app/src/androidTest/kotlin/space/subread/app/CapabilitiesTest.kt new file mode 100644 index 0000000..ba38753 --- /dev/null +++ b/app/src/androidTest/kotlin/space/subread/app/CapabilitiesTest.kt @@ -0,0 +1,51 @@ +package space.subread.app + +import android.content.Context +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import space.subread.app.job.Job +import space.subread.app.video.VideoMaker + +/** A new user sees all that the app can do on the first screen, with no button pressed. */ +@RunWith(AndroidJUnit4::class) +class CapabilitiesTest { + + @get:Rule + val compose = createEmptyComposeRule() + + @Test + fun theFirstScreenSaysAllThatTheAppCanDo() { + val app = InstrumentationRegistry.getInstrumentation().targetContext + app.getSharedPreferences("picks", Context.MODE_PRIVATE).edit().clear().commit() + Job.clear() + + ActivityScenario.launch(MainActivity::class.java).use { + for (words in listOf( + "Subtitles (.srt)", "Video + subtitles", "upload it to YouTube", "Size", "Frames a second", + "What it takes", "m4b, m4a, mp3", "epub, plain text", "Japanese, Spanish, Portuguese, Russian, Finnish", + "How it works", "No network, no account, no permissions", "Continue starts from where it was", + )) compose.onNodeWithText(words, substring = true).assertExists() + // The outputs are real controls, not only words. The options work now; + // the save buttons wait for a job. + for (button in listOf("Save .srt", "Share", "Save video + .srt")) { + compose.onNodeWithText(button).assertExists().assertIsNotEnabled() + } + compose.onNodeWithText(VideoMaker.sizes().first().label).performScrollTo().performClick() + compose.waitForIdle() + // The menu holds the sizes of this device. + assertTrue(compose.onAllNodesWithText(VideoMaker.sizes().last().label).fetchSemanticsNodes().isNotEmpty()) + compose.onNodeWithText("Start").assertExists().assertIsNotEnabled() // nothing is picked + } + } +} diff --git a/app/src/main/kotlin/space/subread/app/MainActivity.kt b/app/src/main/kotlin/space/subread/app/MainActivity.kt index e8aa732..dc4aa90 100644 --- a/app/src/main/kotlin/space/subread/app/MainActivity.kt +++ b/app/src/main/kotlin/space/subread/app/MainActivity.kt @@ -148,6 +148,7 @@ private fun App() { "that any video player plays.", style = MaterialTheme.typography.bodyMedium, ) + if (status.phase == Phase.IDLE) Capabilities() HorizontalDivider(color = Color.Black) Pick("Audiobook", audio?.let { TranscriptStore.describe(context, it).first }, !status.running) { @@ -184,27 +185,55 @@ private fun App() { }, ) { Text(if (resumable) "Continue" else "Start") } - if (a == null || b == null) { - Text("Pick both files to begin. A long book takes hours. Keep this app open: the " + - "screen stays on while it works. If it is interrupted, Start continues from there.", - style = MaterialTheme.typography.bodySmall) - } - Outcome(status, video, - videoOptions = { - Choice("Size", sizes.map { it to it.label }, videoSize) { - videoSize = it - prefs.edit { putString("video_size", it.label) } - } - Choice("Frames a second", VideoMaker.FRAME_RATES.map { it to "$it" }, videoFps) { - videoFps = it - prefs.edit { putInt("video_fps", it) } - } - }, - onSave = { status.srt?.let { saveSrt.launch(it.name) } }, - onShare = { status.srt?.let { share(context, it) } }, - onVideo = { pickVideoFolder.launch(null) }) + Outcome(status) } + // Each output and its options, from the first screen on. The buttons + // wait for the job; a job takes hours, and nobody must run one to + // find out what the app makes. + HorizontalDivider(color = Color.Black) + Outputs(status, video, + videoOptions = { + Choice("Size", sizes.map { it to it.label }, videoSize) { + videoSize = it + prefs.edit { putString("video_size", it.label) } + } + Choice("Frames a second", VideoMaker.FRAME_RATES.map { it to "$it" }, videoFps) { + videoFps = it + prefs.edit { putInt("video_fps", it) } + } + }, + onSave = { status.srt?.let { saveSrt.launch(it.name) } }, + onShare = { status.srt?.let { share(context, it) } }, + onVideo = { pickVideoFolder.launch(null) }) + + + } + } + } +} + +@Composable +private fun Capabilities() { + val named = LANGUAGES.drop(1).take(5).joinToString(", ") { it.second } + val sections = listOf( + "What it takes" to listOf( + "Audio: m4b, m4a, mp3, opus, ogg, flac, wav.", + "Book: epub, plain text, or an Aozora Bunko zip.", + "Language: $named and ${LANGUAGES.size - 6} more, or let the app detect it.", + ), + "How it works" to listOf( + "All on this device. No network, no account, no permissions.", + "The words come from the book, so there are no mistakes of a speech model in them. " + + "Pages that nobody reads aloud (front matter, notes) are left out.", + "A long book takes hours. Keep the app open. If it stops, Continue starts from where it was.", + ), + ) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + for ((title, lines) in sections) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + for (line in lines) Text("• $line", style = MaterialTheme.typography.bodySmall) } } } @@ -275,10 +304,7 @@ private fun Progress(status: JobStatus) { } @Composable -private fun Outcome( - status: JobStatus, video: VideoStatus, videoOptions: @Composable () -> Unit, - onSave: () -> Unit, onShare: () -> Unit, onVideo: () -> Unit, -) { +private fun Outcome(status: JobStatus) { when (status.phase) { Phase.DONE -> Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { val rate = ((status.matchRate ?: 0.0) * 100).toInt() @@ -291,31 +317,6 @@ private fun Outcome( Text("${status.paragraphsDropped} paragraphs of the book were never narrated " + "(front matter, notes) and were left out.", style = MaterialTheme.typography.bodySmall) } - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button(onClick = onSave) { Text("Save .srt") } - OutlinedButton(onClick = onShare) { Text("Share") } - } - Text("In Hoshi Reader: long-press the book, Match, and choose the saved .srt.", - style = MaterialTheme.typography.bodySmall) - - HorizontalDivider(color = Color.Black) - Text("Video + subtitles", fontWeight = FontWeight.Bold) - Text("An .mp4 of the cover and the audio, with the .srt beside it. It plays with subtitles " + - "in any video player, and you can upload it to YouTube.", style = MaterialTheme.typography.bodySmall) - if (video.running) { - LinearProgressIndicator( - progress = { video.fraction }, - modifier = Modifier.fillMaxWidth(), - color = Color.Black, trackColor = Color(0xFFCCCCCC), - ) - Text("Making the video: ${(video.fraction * 100).toInt()}%", style = MaterialTheme.typography.bodySmall) - } else { - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { videoOptions() } - Text("The picture is still, so more frames a second add almost nothing to the file. " + - "A larger size adds a little.", style = MaterialTheme.typography.bodySmall) - OutlinedButton(onClick = onVideo) { Text("Save video + .srt") } - if (video.message.isNotEmpty()) Text(video.message, style = MaterialTheme.typography.bodySmall) - } } Phase.FAILED -> Text("Failed: ${status.detail}", fontWeight = FontWeight.Bold) Phase.CANCELLED -> Text(status.detail) @@ -323,6 +324,45 @@ private fun Outcome( } } +/** What the app makes. The save buttons work when a job is done. */ +@Composable +private fun Outputs( + status: JobStatus, video: VideoStatus, videoOptions: @Composable () -> Unit, + onSave: () -> Unit, onShare: () -> Unit, onVideo: () -> Unit, +) { + val done = status.phase == Phase.DONE && status.srt != null + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Subtitles (.srt)", fontWeight = FontWeight.Bold) + Text("Each line of the book with the time it is read. In Hoshi Reader: long-press the book, " + + "Match, and choose the saved .srt.", style = MaterialTheme.typography.bodySmall) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = onSave, enabled = done) { Text("Save .srt") } + OutlinedButton(onClick = onShare, enabled = done) { Text("Share") } + } + + HorizontalDivider(color = Color.Black) + Text("Video + subtitles", fontWeight = FontWeight.Bold) + Text("An .mp4 of the cover and the audio, with the .srt beside it. It plays with subtitles " + + "in any video player, and you can upload it to YouTube.", style = MaterialTheme.typography.bodySmall) + if (video.running) { + LinearProgressIndicator( + progress = { video.fraction }, + modifier = Modifier.fillMaxWidth(), + color = Color.Black, trackColor = Color(0xFFCCCCCC), + ) + Text("Making the video: ${(video.fraction * 100).toInt()}%", style = MaterialTheme.typography.bodySmall) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { videoOptions() } + Text("The picture is still, so more frames a second add almost nothing to the file " + + "(about 60 MB of video for a 10-hour book). A larger size adds a little.", + style = MaterialTheme.typography.bodySmall) + OutlinedButton(onClick = onVideo, enabled = done) { Text("Save video + .srt") } + if (video.message.isNotEmpty()) Text(video.message, style = MaterialTheme.typography.bodySmall) + } + if (!done) Text("The save buttons work when the job is done.", style = MaterialTheme.typography.bodySmall) + } +} + private fun share(context: Context, srt: File) { val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", srt) val send = Intent(Intent.ACTION_SEND)