Recently Written · git

subread-android

SubRead for Android: times an audiobook against its ebook on the device

git clone https://github.com/equwal/subread-android

Log | Files | Refs


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)