Recently Written · git

subread-anki

One tap makes an Anki card on Android: word, definition, sentence, screenshot, audio. Works with any app, with SubRead Dictionary and SubRead Overlay.

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

Log | Files | Refs


commit 56aa13c3b780012a06621161d7a1eb23dba63e67
equwal <13551856+equwal@users.noreply.github.com>
2026-09-25 14:34:05 -0700

Show a pop-up, the same as a dictionary

A selected text now opens a pop-up over the app, in place of a card made
out of sight. The pop-up shows the text and the terms of SubRead
Dictionary at the word, with a "+ Anki" button for each term. A tap on a
character moves the word. The app needs no other SubRead app: the text
comes from the text selection menu of any app, as "Anki card".

The picture is taken when the text comes in, before the pop-up draws, and
the sound is cut at once. A clip of silence is left out. A request with a
definition is still added at once, with no pop-up.

 README.md                                          | 123 +++---
 app/src/main/AndroidManifest.xml                   |  11 +-
 .../main/kotlin/space/subread/anki/AddActivity.kt  | 491 +++++++++++++++++----
 .../main/kotlin/space/subread/anki/AnkiClient.kt   |  22 +
 .../kotlin/space/subread/anki/CaptureService.kt    |  39 +-
 .../kotlin/space/subread/anki/DictionaryClient.kt  |  23 +-
 .../main/kotlin/space/subread/anki/MainActivity.kt |  49 +-
 app/src/main/kotlin/space/subread/anki/Media.kt    |  18 +
 app/src/main/kotlin/space/subread/anki/Miner.kt    | 190 +++++---
 app/src/main/kotlin/space/subread/anki/Store.kt    |  16 +-
 app/src/main/res/values/strings.xml                |  57 +--
 app/src/main/res/values/styles.xml                 |  12 +
 .../kotlin/space/subread/anki/core/Loudness.kt     |  21 +
 .../main/kotlin/space/subread/anki/core/Scans.kt   |  52 +++
 .../kotlin/space/subread/anki/core/CoreTest.kt     |  70 +++
 .../metadata/android/en-US/full_description.txt    |  12 +-
 .../metadata/android/en-US/short_description.txt   |   2 +-
 17 files changed, 884 insertions(+), 324 deletions(-)
diff --git a/README.md b/README.md
index bc0b0b8..5ad2e82 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,23 @@
 # SubRead Anki
 
-One tap makes an Anki card: the word, its reading and definition, the
-sentence it is in, the sound of that sentence, and a picture of the screen.
-The card goes into [AnkiDroid](https://github.com/ankidroid/Anki-Android)
-on the device.
-
-The tap can come from anywhere: a word of the subtitle line of
-[SubRead Overlay](https://github.com/equwal/subread-overlay), the text
-selection menu of any app (a reader, a browser, an OCR app), the share sheet,
-another app, or a link on a web page. The app fills in what the tap did not
-say: the definition from
-[SubRead Dictionary](https://github.com/equwal/subread-dictionary), the
-sentence and its times from the overlay, the sound and the picture from the
-capture. It shows nothing of its own: a toast says "Added to Anki: 猫" and
-the reader or the player stays in view.
-
-Nothing leaves the device. The app talks to AnkiDroid, SubRead Overlay and
-SubRead Dictionary on the device, and opens a URL only when a sender gives
-one for an audio or a picture.
+A pop-up for Anki cards, used the same way as a dictionary. Select a text
+in any app (a reader, a browser, an OCR app) and choose "Anki card" in the
+text selection menu. The pop-up opens over the app and shows the text and
+the terms at the word, from
+[SubRead Dictionary](https://github.com/equwal/subread-dictionary). A tap on
+a character of the text moves the word there. Tap "+ Anki" on a term: the
+card goes into [AnkiDroid](https://github.com/ankidroid/Anki-Android) with
+the word, its reading and definition, the sentence around the word, a
+picture of the screen and the sound of the sentence.
+
+The app is on its own: it needs no other SubRead app. With SubRead
+Dictionary, the pop-up has the definitions. With
+[SubRead Overlay](https://github.com/equwal/subread-overlay), a subtitle
+word shows its whole line, and the card gets the sound of that line.
+
+Nothing leaves the device. The app talks to AnkiDroid and SubRead
+Dictionary on the device, and opens a URL only when a sender gives one for
+an audio or a picture.
 
 ## How to use it
 
@@ -29,37 +29,39 @@ one for an audio or a picture.
    names: the [Lapis](https://github.com/donkuri/lapis) note type and the
    basic ones work as they are. "Fields" changes the guess.
 3. Install SubRead Dictionary and import a dictionary into it, for the
-   reading, the definition, the pitch accent, the frequency and the word
-   audio.
-4. Install SubRead Overlay, and choose "Anki" as its dictionary, or use its
-   "Anki" button under a selected word. A tap on a word of the subtitle
-   line then makes the card: the line is the sentence, and its times cut
-   the sound.
-5. Start the capture, from the app or from its tile in the quick settings.
-   Android asks each time. While the capture is on, the app keeps the last
-   minute and a half of the sound of the device and the last picture of the
-   screen, and each card gets the sound of its line and the picture. Stop it
-   from the notification or the tile.
-
-In any other app: select a word and choose "Anki" in the text selection
-menu. The sentence comes from the overlay when the word is in the line of
-now. Select a whole sentence, and the card is a sentence card. Share a
-picture to "Anki" within an hour of a card, and it goes onto that card.
-
-Two options: "Ask before adding" shows the sentence and the terms of the
-dictionary first, with one tap to add; off, one tap adds. "A word that is in
-the deck already: skip" keeps a second tap from making a second card.
+   reading, the definition and the word audio.
+4. For a picture and sound on the cards, start the capture, from the app or
+   from its tile in the quick settings. Android asks each time. While the
+   capture is on, the app keeps the last minute and a half of the sound of
+   the device and the newest picture of the screen. Stop it from the
+   notification or the tile.
+
+Then, in any app: select a sentence, choose "Anki card", tap the word in the
+pop-up, and tap "+ Anki". A single selected word works too; the card then
+has no sentence, unless the word is in the subtitle line of SubRead Overlay.
+The button says "✓ Added", and a term that is in the deck already says
+"✓ In Anki". A tap outside the pop-up closes it.
+
+The line under the text says what the card gets: "Picture ✓   Sound 5.3 s ✓
+  Deck: SubRead". The picture is the screen at the moment the text was
+selected, from before the pop-up opened. The sound is the last eight
+seconds before that moment: the audiobook that plays while you read. A clip
+of silence is left out.
+
+Share a picture to "Anki card" within an hour of a card, and it goes onto
+that card. The option "add the first term at once, with no pop-up" makes a
+selection a card with no pop-up.
 
 ## What goes on the card
 
 | Part | Where it comes from |
 |---|---|
-| Expression, reading, definition, pitch, frequency | SubRead Dictionary, for the longest term at the start of the selection. `食べた` gives the card `食べる`. A sender can give its own. |
-| Sentence | The sender, else the subtitle line of now from SubRead Overlay when the selection is in it. The word is in `<b>` as it is in the sentence. |
-| Sentence audio | The sender's file, cut to the times it gives. Else the capture, cut to the times of the subtitle line on the clock of the player, with a pad on each side. Without a line, the eight seconds before the tap. AAC in `.m4a`: every player and every Anki has it. |
+| Expression, reading, definition | The term that you tap "+ Anki" on, from SubRead Dictionary. `食べた` gives the card `食べる`. A sender can give its own. |
+| Sentence | The sentence around the word in the text of the pop-up. The word is in `<b>` as it is in the sentence. |
+| Sentence audio | The sender's file, cut to the times it gives. Else the capture: the subtitle line of SubRead Overlay, cut on the clock of the player with a pad on each side, or the eight seconds before the text came in. AAC in `.m4a`: every player and every Anki has it. |
 | Word audio | The sender, else the audio of SubRead Dictionary (the local audio server, or a remote source when on). |
-| Picture | The sender, else the newest picture of the screen from the capture. JPEG, at most 1600 pixels on the long side. |
-| Source, tags | The sender. The tags of the settings go on every card. |
+| Picture | The sender, else the screen from the capture at the moment the text came in. JPEG, at most 1600 pixels on the long side. |
+| Source, tags | The sender, else the name of the app that sent the text. The tags of the settings go on every card. |
 
 The note type "SubRead Anki" has the fields Expression, Reading, Definition,
 Sentence, SentenceAudio, WordAudio, Image and Source, and one card: the word
@@ -67,15 +69,17 @@ and the sentence in front, the rest behind, black on white.
 
 ## For other apps
 
-Four doors, all into one activity, `space.subread.anki.AddActivity`. It
-shows nothing and closes when the card is added. Any part can be left out:
-the app fills it in as the table above says.
+Four doors, all into one activity, `space.subread.anki.AddActivity`. Any
+part can be left out: the app fills it in as the table above says. A
+request with a `definition` is a whole card: it goes to AnkiDroid at once,
+with no pop-up, and a toast says so. A request without one opens the
+pop-up at the word, so the user picks the term. `confirm` changes that.
 
 **The text selection menu and the share sheet.** No code: the app has an
 entry in both. `Intent.ACTION_PROCESS_TEXT` with `EXTRA_PROCESS_TEXT`, or
 `Intent.ACTION_SEND` with `text/plain` and `EXTRA_TEXT`. The text is the
-word. `ACTION_SEND` with an `image/*` stream and a text is the picture and
-the word; the picture alone goes onto the last card.
+text of the pop-up. `ACTION_SEND` with an `image/*` stream and a text is the
+picture and the text; the picture alone goes onto the last card.
 
 **An Intent from an app on the device.** The action
 `space.subread.anki.action.ADD`, with the keys of the table below as extras,
@@ -141,16 +145,17 @@ location.href = "intent://add?" + query + "#Intent;scheme=subreadanki;package=sp
 The link must come from a tap of the user: browsers block an app link
 without one. An `http(s)` audio is read with range requests, so a whole
 audiobook is fine; a picture is fetched whole. A `data:` Uri works for a
-small file. The app shows a toast and goes back to the browser.
+small file. With a `definition`, the app shows a toast and goes back to the
+browser; without one, the pop-up opens over the browser.
 
 **The keys.** Each is optional; a card needs `expression` or `sentence`.
 
 | Key | Value |
 |---|---|
-| `expression` | The word, as it is in the text. |
+| `expression` | The word, as it is in the text. The pop-up opens at it. |
 | `reading` | The reading of the word. |
-| `definition` | The definition, HTML or text. Given, the dictionary is not asked. |
-| `sentence` | The sentence, plain text. |
+| `definition` | The definition, HTML or text. Given, the card is added at once and the dictionary is not asked. |
+| `sentence` | The sentence, plain text: the text of the pop-up. |
 | `audio` | A Uri of the audio of the sentence: `content://`, `https://`, `http://` or `data:`. |
 | `audio_start`, `audio_end` | With `audio`: the clip, in milliseconds of the audio. Without them, the whole file is the clip. |
 | `word_audio` | A Uri of the audio of the word alone. |
@@ -160,15 +165,19 @@ small file. The app shows a toast and goes back to the browser.
 | `pitch`, `frequency` | Text for the pitch and frequency fields. |
 | `position` | The position of the player at the tap, in milliseconds. With the capture on and no `audio`, the clip is cut around it. |
 | `cue_start`, `cue_end` | The subtitle line, in milliseconds on the clock of the player. With the capture on and no `audio`, the clip is this range. |
-| `confirm` | `1`: show the card before it is added, whatever the setting says. `0`: add at once. |
+| `confirm` | `1`: open the pop-up, also with a definition. `0`: add at once, with the first term of the dictionary when there is no definition. |
 
 ## How it works
 
 `:core` is plain Kotlin, with no Android in it, and has property tests: the
-request and its query, the sentence around a word, the clock of a player
-report, the ring of captured sound, the fields of a note type. `:app` has
-the activity, the capture service, the AnkiDroid client and the two content
-provider clients.
+request and its query, the text of the pop-up and the sentence around a
+word, the clock of a player report, the ring of captured sound and its
+loudness, the fields of a note type. `:app` has the pop-up, the capture
+service, the AnkiDroid client and the two content provider clients.
+
+The pop-up takes the picture when the text comes in, before its own window
+draws, and cuts the sound at once too. Each "+ Anki" of the same pop-up
+uses the same picture and sound.
 
 The sound of the capture is 16-bit PCM in a ring with a clock. A player
 reports a position, the time of that report and the speed; the app maps the
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d12c30a..1de962f 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -41,10 +41,11 @@
         </activity>
 
         <!--
-            The one way in. It shows nothing: it takes the request, fills what is missing,
-            adds the card and closes. Five doors: the text selection menu of Android, the share
-            sheet (a text or a picture), the action space.subread.anki.action.ADD from another
-            app, and the link subreadanki://add from a web page. See README.md.
+            The pop-up, the same as a dictionary: the text, the terms at the word, and one
+            "+ Anki" button for each. Doors: the text selection menu of Android ("Anki card"),
+            the share sheet (a text or a picture), the action space.subread.anki.action.ADD
+            from another app, and the link subreadanki://add from a web page. A request that
+            brings its own definition is added at once, with no pop-up. See README.md.
         -->
         <activity
             android:name=".AddActivity"
@@ -52,7 +53,7 @@
             android:exported="true"
             android:label="@string/add_label"
             android:taskAffinity=""
-            android:theme="@android:style/Theme.Translucent.NoTitleBar">
+            android:theme="@style/Popup">
             <intent-filter>
                 <action android:name="android.intent.action.PROCESS_TEXT" />
                 <category android:name="android.intent.category.DEFAULT" />
diff --git a/app/src/main/kotlin/space/subread/anki/AddActivity.kt b/app/src/main/kotlin/space/subread/anki/AddActivity.kt
index 5616b02..6455f14 100644
--- a/app/src/main/kotlin/space/subread/anki/AddActivity.kt
+++ b/app/src/main/kotlin/space/subread/anki/AddActivity.kt
@@ -1,171 +1,474 @@
 package space.subread.anki
 
+import android.annotation.SuppressLint
 import android.app.Activity
-import android.app.AlertDialog
 import android.content.Intent
 import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.Color
+import android.graphics.Typeface
+import android.media.MediaPlayer
 import android.os.Bundle
+import android.os.SystemClock
+import android.text.Html
+import android.text.SpannableString
+import android.text.Spanned
+import android.text.method.LinkMovementMethod
+import android.text.style.BackgroundColorSpan
+import android.text.style.UnderlineSpan
+import android.view.Gravity
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowManager
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.TextView
 import android.widget.Toast
 import space.subread.anki.core.MineRequest
-import space.subread.anki.core.Sentences
+import space.subread.anki.core.Scan
+import space.subread.anki.core.Scans
+import java.io.File
+import java.io.FileInputStream
+import java.util.concurrent.Callable
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
 import kotlin.concurrent.thread
 
 /**
- * The one way in. It takes the request from the Intent, has [Miner] fill and add the card,
- * says what happened in a toast, and closes. It draws nothing: the app below stays in view.
+ * The pop-up. It takes a text the way a dictionary does: from the text selection menu of any
+ * app, the share sheet, another app, or a link. It shows the text, the terms of SubRead
+ * Dictionary at the word, and a "+ Anki" button for each term. A tap on a character of the
+ * text moves the word there. The card gets the term, the sentence around the word, and the
+ * picture and the sound of the moment the text came in.
  *
- * The result for a caller that started it for one: `RESULT_OK` with
- * [Requests.EXTRA_NOTE_ID], or `RESULT_CANCELED` with [Requests.EXTRA_ERROR].
+ * A request that brings its own definition, or the setting "add at once", makes the card with
+ * no pop-up: the window stays clear, and a toast says what happened.
+ *
+ * The result for a caller that started it for one: `RESULT_OK` with [Requests.EXTRA_NOTE_ID]
+ * of the last card, or `RESULT_CANCELED` with [Requests.EXTRA_ERROR].
  */
 class AddActivity : Activity() {
 
     private lateinit var store: Store
-    private var pending: MineRequest? = null
-    private var addedNoteId: Long? = null
+    private lateinit var miner: Miner
+
+    /** One thread for the media and the cards, in order: a card waits for the media of its text. */
+    private val work: ExecutorService = Executors.newSingleThreadExecutor()
+    private var request = MineRequest()
+    private var grab: Future<Miner.Grab>? = null
+
+    /** A card to add with no pop-up, once AnkiDroid allows it. */
+    private var pending: Scan? = null
+    private var text = ""
+    private var generation = 0
+    private var player: MediaPlayer? = null
+    private lateinit var textView: TextView
+    private lateinit var status: TextView
+    private lateinit var results: LinearLayout
+
+    /** The "+ Anki" button of each term on the screen, with its expression. */
+    private val addButtons = ArrayList<Pair<String, Button>>()
 
     override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
+        // The screen of this moment, before a window of this app draws: the app that sent the text.
+        val shot: Bitmap? = CaptureService.instance?.snapshot()
+        val grabbedAt = SystemClock.elapsedRealtimeNanos()
         store = Store(this)
-        val request = Requests.fromIntent(intent)
-        if (request == null || (request.isEmpty && request.image == null)) {
+        val parsed = Requests.fromIntent(intent)
+        val pictureOnly = parsed != null && parsed.isEmpty && parsed.image != null
+        val popup = parsed != null && !parsed.isEmpty && !addsAtOnce(parsed)
+        // Without the pop-up, the window stays empty and clear.
+        if (!popup) setTheme(android.R.style.Theme_Translucent_NoTitleBar)
+        super.onCreate(savedInstanceState)
+        miner = Miner(this)
+        if (parsed == null || (parsed.isEmpty && !pictureOnly)) {
             toast(getString(R.string.no_text))
-            finishWith(null, "empty")
+            finishWith("empty")
             return
         }
+        request = parsed
         if (AnkiClient.packageName(this) == null) {
             toast(getString(R.string.anki_missing))
             startActivity(Intent(this, MainActivity::class.java))
-            finishWith(null, "no_ankidroid")
+            finishWith("no_ankidroid")
             return
         }
-        if (!AnkiClient.hasPermission(this)) {
-            pending = request
-            requestPermissions(arrayOf(AnkiClient.PERMISSION), PERMISSION)
+        val image = parsed.image
+        if (pictureOnly && image != null) {
+            work.execute { finishAfter(runCatching { miner.attachPicture(image) }.getOrElse { Miner.Result.Failed(it.message ?: it.toString()) }) }
             return
         }
-        start(request)
+        val overlay = if (parsed.sentence.isNullOrBlank()) OverlayClient.now(this) else null
+        val scan = Scans.resolve(parsed.expression, parsed.sentence, overlay?.line?.text) ?: run {
+            finishWith("empty")
+            return
+        }
+        val line = if (scan.fromLine) overlay else null
+        val source = parsed.source ?: sourceName(line?.player)
+        grab = work.submit(Callable { miner.grab(parsed, shot, grabbedAt, line, source) })
+        val allowed = AnkiClient.hasPermission(this)
+        if (!allowed) requestPermissions(arrayOf(AnkiClient.PERMISSION), PERMISSION)
+        if (!popup) {
+            if (allowed) addAtOnce(scan) else pending = scan
+            return
+        }
+        draw()
+        show(scan)
+        work.execute {
+            val grabbed = runCatching { grab?.get() }.getOrNull()
+            val deck = runCatching { AnkiClient(this).deckName() }.getOrDefault(AnkiClient.DECK_NAME)
+            runOnUiThread { if (!isFinishing && grabbed != null) status.text = statusLine(grabbed, deck) }
+        }
     }
 
+    /** True when the card goes to Anki with no pop-up. */
+    private fun addsAtOnce(r: MineRequest): Boolean =
+        r.confirm == false || (r.confirm == null && (r.definition != null || store.addAtOnce))
+
     override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
         super.onRequestPermissionsResult(requestCode, permissions, grantResults)
-        val request = pending ?: return
+        val scan = pending
         pending = null
         if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
-            start(request)
+            if (scan != null) addAtOnce(scan)
         } else {
             toast(getString(R.string.anki_no_permission))
-            finishWith(null, "no_permission")
+            if (scan != null) finishWith("no_permission")
         }
     }
 
-    private fun start(request: MineRequest) {
-        val image = request.image
-        if (request.isEmpty && image != null) {
-            // A picture alone: for the last card.
-            thread {
-                val result = runCatching { Miner(this).attachPicture(image) }.getOrElse { Miner.Result.Failed(it.message ?: it.toString()) }
-                runOnUiThread { report(result) }
-            }
-            return
-        }
-        thread {
-            val plan = runCatching { Miner(this).prepare(request) }.getOrElse { e ->
-                runOnUiThread { report(Miner.Result.Failed(e.message ?: e.toString())) }
-                return@thread
-            }
-            runOnUiThread {
-                if (isFinishing) return@runOnUiThread
-                if (request.confirm ?: store.confirm) confirm(plan) else commit(plan, plan.entries.firstOrNull())
-            }
-        }
+    override fun onDestroy() {
+        player?.release()
+        player = null
+        work.shutdown()
+        super.onDestroy()
     }
 
-    /** Shows the sentence and the terms of the dictionary; the user picks one and adds. */
-    private fun confirm(plan: Miner.Plan) {
-        var chosen = 0
-        val media = getString(
-            R.string.confirm_media,
-            yesNo(plan.sentenceAudio != null), yesNo(plan.image != null), yesNo(plan.entries.firstOrNull()?.audio != null),
-        )
-        val builder = AlertDialog.Builder(this)
-            .setTitle(plan.sentence ?: plan.selection ?: getString(R.string.confirm_title))
-            .setPositiveButton(R.string.confirm_add) { _, _ -> commit(plan, plan.entries.getOrNull(chosen)) }
-            .setNegativeButton(R.string.confirm_cancel) { _, _ -> finishWith(null, "cancelled") }
-            .setOnCancelListener { finishWith(null, "cancelled") }
-        if (plan.entries.isEmpty()) {
-            builder.setMessage((plan.selection ?: getString(R.string.confirm_sentence_only)) + "\n\n" + media)
-        } else {
-            val labels = plan.entries.map { it.headword + "\n" + Sentences.unescape(Sentences.stripTags(it.glossary.replace("<br>", " "))).take(80) }
-            builder.setSingleChoiceItems((labels + media).toTypedArray(), 0) { dialog, i ->
-                // The last line says what media the card has: it is not a choice.
-                if (i < labels.size) chosen = i else (dialog as AlertDialog).listView.setItemChecked(chosen, true)
-            }
+    /** The card with no pop-up: the definition of the request, else the first term at the word. */
+    private fun addAtOnce(scan: Scan) {
+        work.execute {
+            val result = runCatching {
+                val word = scan.word
+                val card = if (request.definition != null) {
+                    if (word != null && scan.offset >= 0) Miner.card(request, scan.text, scan.offset, word.length, null)
+                    else Miner.card(request, scan.text, -1, 0, null)
+                } else if (scan.offset >= 0) {
+                    val entry = DictionaryClient.lookup(this, scan.text, scan.offset).firstOrNull()
+                    if (entry != null) {
+                        Miner.card(request, scan.text, scan.offset, entry.length, entry)
+                    } else {
+                        val range = Miner.wordAt(scan.text, scan.offset, selected = isSelection(scan.text, scan.offset))
+                        Miner.card(request, scan.text, range.first, range.count(), null)
+                    }
+                } else {
+                    Miner.card(request, scan.text, -1, 0, DictionaryClient.lookup(this, word.orEmpty()).firstOrNull())
+                }
+                miner.add(grab!!.get(), card)
+            }.getOrElse { Miner.Result.Failed(it.message ?: it.toString()) }
+            finishAfter(result)
         }
-        builder.show()
     }
 
-    private fun commit(plan: Miner.Plan, entry: DictionaryClient.Entry?) {
-        thread {
-            val result = runCatching { Miner(this).add(plan, entry) }.getOrElse { Miner.Result.Failed(it.message ?: it.toString()) }
-            runOnUiThread { report(result) }
-        }
-    }
+    /** True for the first scan of a text that is the selection itself: the user chose the ends of the word. */
+    private fun isSelection(text: String, at: Int): Boolean = at == 0 && text == request.expression?.trim()
 
-    private fun report(result: Miner.Result) {
+    /** From the work thread: says what happened, and closes. */
+    private fun finishAfter(result: Miner.Result) = runOnUiThread {
         when (result) {
             is Miner.Result.Added -> {
-                toast(if (result.expression.isEmpty()) getString(R.string.added_sentence) else getString(R.string.added, result.expression))
-                if (store.openDictionary && result.expression.isNotEmpty() && DictionaryClient.installed(this)) {
-                    addedNoteId = result.noteId
-                    openDictionary(result.expression)
-                } else {
-                    finishWith(result.noteId, null)
-                }
+                toast(getString(R.string.added, result.expression))
+                setResult(RESULT_OK, Intent().putExtra(Requests.EXTRA_NOTE_ID, result.noteId))
+                finish()
             }
             is Miner.Result.Duplicate -> {
                 toast(getString(R.string.duplicate, result.expression))
-                finishWith(null, "duplicate")
+                finishWith("duplicate")
             }
             Miner.Result.Attached -> {
                 toast(getString(R.string.picture_attached))
-                finishWith(null, null)
+                finishWith(null)
             }
             is Miner.Result.Failed -> {
                 toast(getString(R.string.failed, result.why))
-                finishWith(null, result.why)
+                finishWith(result.why)
+            }
+        }
+    }
+
+    /** Where the text is from: the player of the subtitle line, else the app that sent the text. */
+    private fun sourceName(player: String?): String {
+        val sender = player ?: referrer?.takeIf { it.scheme == "android-app" }?.host
+        if (sender == null || sender == packageName || sender.startsWith(OverlayClient.PACKAGE)) return ""
+        return runCatching { packageManager.getApplicationLabel(packageManager.getApplicationInfo(sender, 0)).toString() }.getOrDefault("")
+    }
+
+    // The pop-up. The same frame as the pop-up of SubRead Dictionary: a panel at the bottom,
+    // over the app that sent the text, black on white, nothing that moves.
+
+    // The touch listener finds the character under the finger; it calls performClick itself.
+    @SuppressLint("ClickableViewAccessibility")
+    private fun draw() {
+        val height = (resources.displayMetrics.heightPixels * 0.6).toInt()
+        window.attributes = window.attributes.apply {
+            width = ViewGroup.LayoutParams.MATCH_PARENT
+            this.height = height
+            gravity = Gravity.BOTTOM
+        }
+        window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
+        textView = TextView(this).apply {
+            textSize = TEXT_SP + 3
+            setTextColor(Color.BLACK)
+            maxLines = 5
+            setPadding(dp(16), dp(4), dp(16), dp(4))
+            setOnTouchListener { view, event ->
+                if (event.action == MotionEvent.ACTION_UP) {
+                    val at = (view as TextView).getOffsetForPosition(event.x, event.y)
+                    if (at in text.indices) scanAt(at)
+                    view.performClick()
+                }
+                true
+            }
+        }
+        status = TextView(this).apply {
+            textSize = 13f
+            setTextColor(Color.DKGRAY)
+            setPadding(dp(16), 0, dp(16), dp(8))
+            text = "…"
+        }
+        results = LinearLayout(this).apply {
+            orientation = LinearLayout.VERTICAL
+            setPadding(dp(16), 0, dp(16), dp(16))
+        }
+        val bar = LinearLayout(this).apply {
+            gravity = Gravity.END
+            addView(smallButton(getString(R.string.settings)) { startActivity(Intent(this@AddActivity, MainActivity::class.java)) })
+            addView(smallButton(getString(R.string.close)) { finish() })
+        }
+        setContentView(
+            LinearLayout(this).apply {
+                orientation = LinearLayout.VERTICAL
+                setBackgroundColor(Color.WHITE)
+                addView(bar, LinearLayout.LayoutParams(-1, -2))
+                addView(textView, LinearLayout.LayoutParams(-1, -2))
+                addView(status, LinearLayout.LayoutParams(-1, -2))
+                addView(View(this@AddActivity).apply { setBackgroundColor(Color.LTGRAY) }, LinearLayout.LayoutParams(-1, dp(1)))
+                addView(ScrollView(this@AddActivity).apply { addView(results) }, LinearLayout.LayoutParams(-1, 0, 1f))
+            },
+            ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height),
+        )
+    }
+
+    private fun show(scan: Scan) {
+        text = scan.text
+        textView.text = text
+        if (scan.offset >= 0) scanAt(scan.offset) else lookUpWord(scan.word.orEmpty())
+    }
+
+    /** Looks up the terms that start at [at] in the text. */
+    private fun scanAt(at: Int) {
+        val run = ++generation
+        mark(null)
+        thread {
+            val found = DictionaryClient.lookup(this, text, at)
+            runOnUiThread { if (run == generation && !isFinishing) showTerms(found, at) }
+        }
+    }
+
+    /** The word of the sender is not in the text: its terms, with nothing marked. */
+    private fun lookUpWord(word: String) {
+        val run = ++generation
+        thread {
+            val found = DictionaryClient.lookup(this, word)
+            runOnUiThread { if (run == generation && !isFinishing) showTerms(found, -1) }
+        }
+    }
+
+    /** One block for each term. [at] is where the terms start in the text, -1 when not in it. */
+    private fun showTerms(found: List<DictionaryClient.Entry>, at: Int) {
+        results.removeAllViews()
+        addButtons.clear()
+        if (found.isEmpty()) {
+            // No dictionary, or no term here: the word at the tap goes on the card as it is.
+            val range = if (at >= 0) Miner.wordAt(text, at, selected = isSelection(text, at)) else IntRange.EMPTY
+            if (at >= 0) mark(range)
+            note(getString(if (DictionaryClient.answers(this)) R.string.no_term else R.string.no_dictionary), Color.DKGRAY, TEXT_SP - 3)
+            val word = if (at >= 0) text.substring(range.first, range.last + 1) else request.expression.orEmpty()
+            term(word, null, if (at >= 0) range.first else -1, range.count())
+            return
+        }
+        if (at >= 0) mark(at until at + found.first().length)
+        for (entry in found) term(entry.headword, entry, at, entry.length)
+        markInAnki(found)
+    }
+
+    private fun term(headword: String, entry: DictionaryClient.Entry?, start: Int, length: Int) {
+        results.addView(View(this).apply { setBackgroundColor(Color.LTGRAY) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(10) })
+        val add = Button(this).apply {
+            text = getString(R.string.add_card)
+            isAllCaps = false
+            textSize = 16f
+            setTypeface(typeface, Typeface.BOLD)
+            setOnClickListener { addCard(this, entry, start, length) }
+        }
+        addButtons += (entry?.expression ?: headword) to add
+        results.addView(
+            LinearLayout(this).apply {
+                gravity = Gravity.CENTER_VERTICAL
+                addView(TextView(this@AddActivity).apply {
+                    text = headword
+                    textSize = TEXT_SP + 5
+                    setTypeface(typeface, Typeface.BOLD)
+                    setTextColor(Color.BLACK)
+                }, LinearLayout.LayoutParams(0, -2, 1f))
+                if (entry?.audio != null) addView(smallButton("▶") { play(entry) })
+                addView(add)
+            },
+            LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(6) },
+        )
+        if (entry == null) return
+        val meta = listOf(entry.reasons, entry.frequency, entry.pitch).filter { it.isNotEmpty() }
+        if (meta.isNotEmpty()) note(meta.joinToString("   "), Color.DKGRAY, TEXT_SP - 3)
+        results.addView(
+            TextView(this).apply {
+                text = Html.fromHtml(entry.glossary, Html.FROM_HTML_MODE_COMPACT)
+                textSize = TEXT_SP
+                setTextColor(Color.BLACK)
+                movementMethod = LinkMovementMethod.getInstance()
+            },
+            LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(2) },
+        )
+    }
+
+    /** One tap: the card goes to AnkiDroid. The button says when it is there. */
+    private fun addCard(button: Button, entry: DictionaryClient.Entry?, start: Int, length: Int) {
+        val grabbed = grab ?: return
+        button.isEnabled = false
+        button.text = "…"
+        val card = Miner.card(request, text, start, length, entry)
+        work.execute {
+            val result = runCatching { miner.add(grabbed.get(), card) }.getOrElse { Miner.Result.Failed(it.message ?: it.toString()) }
+            runOnUiThread {
+                when (result) {
+                    is Miner.Result.Added -> {
+                        button.text = getString(R.string.added_short)
+                        setResult(RESULT_OK, Intent().putExtra(Requests.EXTRA_NOTE_ID, result.noteId))
+                    }
+                    is Miner.Result.Duplicate -> button.text = getString(R.string.in_anki)
+                    is Miner.Result.Failed -> {
+                        button.isEnabled = true
+                        button.text = getString(R.string.add_card)
+                        toast(getString(R.string.failed, result.why))
+                    }
+                    Miner.Result.Attached -> Unit
+                }
             }
         }
     }
 
-    /** Opens the pop-up of SubRead Dictionary over the app, and closes when it closes. */
-    @Suppress("DEPRECATION") // The result API of AndroidX would bring AppCompat in for one call.
-    private fun openDictionary(text: String) {
-        val intent = Intent(DICTIONARY_LOOKUP).setPackage(DictionaryClient.PACKAGE).putExtra(Intent.EXTRA_TEXT, text)
-        runCatching { startActivityForResult(intent, DICTIONARY) }.onFailure { finishWith(addedNoteId, null) }
+    /** A term that is in the deck already gets "In Anki" in place of "+ Anki". */
+    private fun markInAnki(found: List<DictionaryClient.Entry>) {
+        if (!store.skipDuplicates || !AnkiClient.hasPermission(this)) return
+        val run = generation
+        thread {
+            val anki = AnkiClient(this)
+            val inAnki = found.map { it.expression }.distinct().filter { runCatching { anki.inAnki(it) }.getOrDefault(false) }.toSet()
+            runOnUiThread {
+                if (run != generation) return@runOnUiThread
+                for ((expression, button) in addButtons) {
+                    if (expression in inAnki) {
+                        button.isEnabled = false
+                        button.text = getString(R.string.in_anki)
+                    }
+                }
+            }
+        }
     }
 
-    @Suppress("DEPRECATION")
-    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
-        super.onActivityResult(requestCode, resultCode, data)
-        if (requestCode == DICTIONARY) finishWith(addedNoteId, null)
+    /** Marks the word in the text; null clears the mark. */
+    private fun mark(range: IntRange?) {
+        val shown = SpannableString(text)
+        if (range != null && !range.isEmpty() && range.last < text.length) {
+            shown.setSpan(BackgroundColorSpan(0xFFFFE082.toInt()), range.first, range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+            // The underline stays visible on an e-ink screen, where the colour is a light grey.
+            shown.setSpan(UnderlineSpan(), range.first, range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+        }
+        textView.text = shown
     }
 
-    private fun finishWith(noteId: Long?, error: String?) {
-        if (noteId != null) {
-            setResult(RESULT_OK, Intent().putExtra(Requests.EXTRA_NOTE_ID, noteId))
+    /** What the card gets besides the term: the picture, the sound, and the deck. */
+    private fun statusLine(grabbed: Miner.Grab, deck: String): String {
+        val parts = ArrayList<String>()
+        val captureOff = CaptureService.instance == null
+        if (captureOff && grabbed.picture == null && grabbed.sentenceAudio == null) {
+            parts += getString(R.string.status_capture_off)
         } else {
-            setResult(RESULT_CANCELED, Intent().putExtra(Requests.EXTRA_ERROR, error.orEmpty()))
+            parts += getString(if (grabbed.picture != null) R.string.status_picture else R.string.status_no_picture)
+            val ms = grabbed.soundMs
+            parts += when {
+                grabbed.sentenceAudio == null -> getString(R.string.status_no_sound)
+                ms != null -> getString(R.string.status_sound_seconds, "%.1f".format(ms / 1000.0))
+                else -> getString(R.string.status_sound)
+            }
+        }
+        parts += getString(R.string.deck_current, deck)
+        return parts.joinToString("   ")
+    }
+
+    /** Plays the audio of the word from the dictionary. */
+    private fun play(entry: DictionaryClient.Entry) {
+        val ask = entry.audio ?: return
+        thread {
+            val bytes = DictionaryClient.audio(this, ask)
+            runOnUiThread {
+                if (bytes == null) {
+                    toast(getString(R.string.no_audio))
+                    return@runOnUiThread
+                }
+                player?.release()
+                val file = File(cacheDir, "play.tmp").apply { writeBytes(bytes) }
+                player = runCatching {
+                    MediaPlayer().apply {
+                        FileInputStream(file).use { setDataSource(it.fd) }
+                        prepare()
+                        start()
+                    }
+                }.getOrNull()
+            }
         }
+    }
+
+    private fun finishWith(error: String?) {
+        setResult(RESULT_CANCELED, Intent().putExtra(Requests.EXTRA_ERROR, error.orEmpty()))
         finish()
     }
 
-    private fun yesNo(value: Boolean) = getString(if (value) R.string.yes else R.string.no)
+    private fun note(value: String, color: Int, size: Float) = results.addView(
+        TextView(this).apply {
+            text = value
+            textSize = size
+            setTextColor(color)
+        },
+        LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(8) },
+    )
+
+    private fun smallButton(label: String, onClick: () -> Unit) = Button(this).apply {
+        text = label
+        isAllCaps = false
+        textSize = 13f
+        setOnClickListener { onClick() }
+    }
 
     private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
 
+    private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
+
     companion object {
         private const val PERMISSION = 1
-        private const val DICTIONARY = 2
-        const val DICTIONARY_LOOKUP = "space.subread.dictionary.LOOKUP"
+
+        /** The text size of the pop-up, in sp: the same as SubRead Dictionary. */
+        private const val TEXT_SP = 17f
     }
 }
diff --git a/app/src/main/kotlin/space/subread/anki/AnkiClient.kt b/app/src/main/kotlin/space/subread/anki/AnkiClient.kt
index 851043c..5315d56 100644
--- a/app/src/main/kotlin/space/subread/anki/AnkiClient.kt
+++ b/app/src/main/kotlin/space/subread/anki/AnkiClient.kt
@@ -59,6 +59,28 @@ class AnkiClient(private val context: Context) {
         return id
     }
 
+    /** The note type of the cards when it exists already, or null. Makes nothing: for a look before the first card. */
+    fun existingModel(): Long? {
+        val models = models()
+        val chosen = store.modelId
+        if (chosen != Store.NONE && models.containsKey(chosen)) return chosen
+        return models.entries.firstOrNull { it.value == NoteType.NAME }?.key
+    }
+
+    /** The name of the deck that the cards go to. */
+    fun deckName(): String = decks()[store.deckId] ?: DECK_NAME
+
+    /**
+     * True when a note of the note type has [expression] in its first field, and that field
+     * holds the word. False when the note type does not exist yet.
+     */
+    fun inAnki(expression: String): Boolean {
+        val modelId = existingModel() ?: return false
+        val fields = fields(modelId)
+        if (fields.isEmpty() || mapping(modelId, fields)[fields.first()] != Source.EXPRESSION) return false
+        return isDuplicate(modelId, expression)
+    }
+
     /** What fills each field of [modelId]: the mapping the user set, else a guess from the names. */
     fun mapping(modelId: Long, fields: List<String>): Map<String, Source> {
         val set = store.mapping
diff --git a/app/src/main/kotlin/space/subread/anki/CaptureService.kt b/app/src/main/kotlin/space/subread/anki/CaptureService.kt
index 485534a..92665b6 100644
--- a/app/src/main/kotlin/space/subread/anki/CaptureService.kt
+++ b/app/src/main/kotlin/space/subread/anki/CaptureService.kt
@@ -30,7 +30,7 @@ import android.util.DisplayMetrics
 import android.view.Display
 import androidx.core.content.IntentCompat
 import androidx.core.graphics.createBitmap
-import androidx.core.graphics.scale
+import space.subread.anki.core.Loudness
 import space.subread.anki.core.PcmRing
 import java.io.File
 import kotlin.concurrent.thread
@@ -188,33 +188,26 @@ class CaptureService : Service() {
 
     /**
      * The sound between two moments of the ring clock, as AAC in [out]. Null when the ring
-     * has none of it, or less than a fifth of a second.
+     * has none of it, less than a fifth of a second, or only silence: a reader app plays no
+     * sound, and a card with a silent clip is worse than a card without one.
      */
     fun clip(fromNanos: Long, toNanos: Long, out: File): File? {
         val pcm = synchronized(ringLock) { ring.read(fromNanos, toNanos) }
-        if (pcm.size < SAMPLE_RATE / 5) return null
+        if (pcm.size < SAMPLE_RATE / 5 || Loudness.isSilent(pcm)) return null
         return runCatching { AacEncoder.encode(pcm, SAMPLE_RATE, 1, out); out }.getOrNull()
     }
 
-    /** The newest picture of the screen, as a JPEG in [out]. Null when there is none yet. */
-    fun screenshot(out: File): File? {
-        val bitmap = synchronized(imageLock) {
-            val image = latest ?: return null
-            val plane = image.planes[0]
-            val stride = plane.rowStride / plane.pixelStride
-            val full = createBitmap(stride, image.height)
-            full.copyPixelsFromBuffer(plane.buffer.also { it.rewind() })
-            if (stride == image.width) full else Bitmap.createBitmap(full, 0, 0, image.width, image.height)
-        }
-        val longest = maxOf(bitmap.width, bitmap.height)
-        val scaled = if (longest <= MAX_SIDE) bitmap else {
-            val scale = MAX_SIDE.toFloat() / longest
-            bitmap.scale((bitmap.width * scale).toInt(), (bitmap.height * scale).toInt())
-        }
-        return runCatching {
-            out.outputStream().use { scaled.compress(Bitmap.CompressFormat.JPEG, 85, it) }
-            out
-        }.getOrNull()
+    /**
+     * The newest picture of the screen. Called when a text comes in, before the pop-up draws,
+     * so the picture shows the app that sent the text. Null when there is none yet.
+     */
+    fun snapshot(): Bitmap? = synchronized(imageLock) {
+        val image = latest ?: return null
+        val plane = image.planes[0]
+        val stride = plane.rowStride / plane.pixelStride
+        val full = createBitmap(stride, image.height)
+        full.copyPixelsFromBuffer(plane.buffer.also { it.rewind() })
+        if (stride == image.width) full else Bitmap.createBitmap(full, 0, 0, image.width, image.height)
     }
 
     override fun onDestroy() {
@@ -239,8 +232,6 @@ class CaptureService : Service() {
         const val SAMPLE_RATE = 44_100
         /** How much sound the ring keeps. */
         const val SECONDS = 90
-        /** The longest side of the picture on the card, in pixels. */
-        const val MAX_SIDE = 1600
         const val CHANNEL = "capture"
         const val NOTIFICATION_ID = 1
         const val ACTION_STOP = "space.subread.anki.action.STOP_CAPTURE"
diff --git a/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt b/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt
index ac7fb11..f64a946 100644
--- a/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt
+++ b/app/src/main/kotlin/space/subread/anki/DictionaryClient.kt
@@ -30,6 +30,8 @@ object DictionaryClient {
         val reading: String,
         /** How many characters of the text the term covers. */
         val length: Int,
+        /** The deinflection from the text to the term, for example `past`. Empty for the dictionary form. */
+        val reasons: String,
         /** HTML: the name of each dictionary and its glossary. */
         val glossary: String,
         val frequency: String,
@@ -47,19 +49,27 @@ object DictionaryClient {
     /** True when the installed dictionary has the provider: the versions before it do not. */
     fun answers(context: Context): Boolean = authority(context) != null
 
-    /** The terms at the start of [text]. Empty when the dictionary is not there or has no term. */
-    fun lookup(context: Context, text: String): List<Entry> {
+    /**
+     * The terms that start at [offset] in [text], the longest first. Empty when the dictionary
+     * is not there or has no term there.
+     */
+    fun lookup(context: Context, text: String, offset: Int = 0): List<Entry> {
         val authority = authority(context)
-        if (text.isEmpty() || authority == null) return emptyList()
-        val uri = "content://$authority/terms".toUri().buildUpon().appendQueryParameter("text", text).build()
+        if (text.isEmpty() || offset !in text.indices || authority == null) return emptyList()
+        val uri = "content://$authority/terms".toUri().buildUpon()
+            .appendQueryParameter("text", text).appendQueryParameter("offset", offset.toString()).build()
         val cursor = runCatching { context.contentResolver.query(uri, null, null, null, null) }.getOrNull() ?: return emptyList()
-        data class Row(val expression: String, val reading: String, val length: Int, val dictionary: String, val glossary: String, val frequency: String, val pitch: String)
+        data class Row(
+            val expression: String, val reading: String, val length: Int, val reasons: String,
+            val dictionary: String, val glossary: String, val frequency: String, val pitch: String,
+        )
         val rows = cursor.use { c ->
             buildList {
                 while (c.moveToNext()) add(
                     Row(
                         c.text("expression") ?: continue, c.text("reading").orEmpty(), c.long("length")?.toInt() ?: 0,
-                        c.text("dictionary").orEmpty(), c.text("definition_html") ?: c.text("glossary").orEmpty(),
+                        c.text("reasons").orEmpty(), c.text("dictionary").orEmpty(),
+                        c.text("definition_html") ?: c.text("glossary").orEmpty(),
                         c.text("frequency").orEmpty(), c.text("pitch").orEmpty(),
                     ),
                 )
@@ -71,6 +81,7 @@ object DictionaryClient {
                 expression = key.first,
                 reading = key.second,
                 length = first.length,
+                reasons = first.reasons,
                 glossary = group.joinToString("<br>") { row ->
                     if (row.dictionary.isEmpty()) row.glossary else "<i>${escape(row.dictionary)}</i><br>${row.glossary}"
                 },
diff --git a/app/src/main/kotlin/space/subread/anki/MainActivity.kt b/app/src/main/kotlin/space/subread/anki/MainActivity.kt
index 8d3becd..0147eae 100644
--- a/app/src/main/kotlin/space/subread/anki/MainActivity.kt
+++ b/app/src/main/kotlin/space/subread/anki/MainActivity.kt
@@ -18,15 +18,14 @@ import android.widget.TextView
 import android.widget.Toast
 import androidx.core.net.toUri
 import space.subread.anki.core.Fields
-import space.subread.anki.core.MineRequest
 import space.subread.anki.core.NoteType
 import space.subread.anki.core.Source
 import kotlin.concurrent.thread
 
 /**
- * The one screen of the app: AnkiDroid, the deck and the note type, the SubRead apps, the
- * capture, the options, and a field to try a card. Each part is on the screen from the
- * start, so that the user sees what the app does before a button is pressed.
+ * The settings: AnkiDroid, the deck and the note type, the dictionary, the capture, the
+ * options, and a field to try the pop-up. Each part is on the screen from the start, so that
+ * the user sees what the app does before a button is pressed.
  */
 class MainActivity : Activity() {
 
@@ -75,10 +74,9 @@ class MainActivity : Activity() {
         if (allowed) row(button(getString(R.string.model_choose)) { chooseModel() }, button(getString(R.string.fields)) { editFields() })
         note(getString(R.string.fields_why), top = 4)
 
-        val overlay = installed(OverlayClient.PACKAGE)
         val dictionary = DictionaryClient.installed(this)
-        step(R.string.step_subread, getString(if (overlay) R.string.overlay_ok else R.string.overlay_missing), null) {}
-        note(
+        step(
+            R.string.step_dictionary,
             getString(
                 when {
                     !dictionary -> R.string.dictionary_missing
@@ -86,13 +84,11 @@ class MainActivity : Activity() {
                     else -> R.string.dictionary_ok
                 },
             ),
-        )
-        row(
-            if (overlay) button(getString(R.string.open_overlay)) { launch(OverlayClient.PACKAGE) }
-            else button(getString(R.string.install_overlay)) { open(OVERLAY_INSTALL) },
-            if (dictionary) button(getString(R.string.open_dictionary)) { launch(DictionaryClient.PACKAGE) }
-            else button(getString(R.string.install_dictionary)) { open(DICTIONARY_INSTALL) },
-        )
+            getString(if (dictionary) R.string.open_dictionary else R.string.install_dictionary),
+            done = DictionaryClient.answers(this),
+        ) {
+            if (dictionary) launch(DictionaryClient.PACKAGE) else open(DICTIONARY_INSTALL)
+        }
 
         val capturing = CaptureService.instance != null
         step(
@@ -104,17 +100,16 @@ class MainActivity : Activity() {
             if (capturing) CaptureService.stop(this) else startActivity(Intent(this, CaptureActivity::class.java))
             content.postDelayed({ draw() }, 500)
         }
+        note(getString(R.string.capture_overlay), top = 12)
         note(getString(R.string.pad), top = 12)
         numberField(store.padMs) { store.padMs = it }
 
-        step(R.string.step_options, getString(if (store.confirm) R.string.confirm_on else R.string.confirm_off), getString(R.string.toggle)) {
-            store.confirm = !store.confirm
+        step(R.string.step_options, getString(if (store.addAtOnce) R.string.popup_off else R.string.popup_on), getString(R.string.toggle)) {
+            store.addAtOnce = !store.addAtOnce
             draw()
         }
         note(getString(if (store.skipDuplicates) R.string.duplicates_skip else R.string.duplicates_add), top = 12)
         content.addView(button(getString(R.string.toggle)) { store.skipDuplicates = !store.skipDuplicates; draw() }, narrow())
-        note(getString(if (store.openDictionary) R.string.open_dictionary_on else R.string.open_dictionary_off), top = 12)
-        content.addView(button(getString(R.string.toggle)) { store.openDictionary = !store.openDictionary; draw() }, narrow())
         note(getString(R.string.tags), top = 12)
         textField(store.tags) { store.tags = it }
 
@@ -124,9 +119,15 @@ class MainActivity : Activity() {
             setTextColor(Color.BLACK)
         }
         content.addView(tryField, wide())
-        content.addView(button(getString(R.string.try_add)) {
+        content.addView(button(getString(R.string.try_look_up)) {
             val text = tryField.text.toString().trim()
-            if (text.isNotEmpty()) startActivity(Requests.toIntent(MineRequest(expression = text)).setClass(this, AddActivity::class.java))
+            // The same intent as the text selection menu, so "Try it" opens the same pop-up.
+            if (text.isNotEmpty()) {
+                startActivity(
+                    Intent(Intent.ACTION_PROCESS_TEXT).setType("text/plain").setClass(this, AddActivity::class.java)
+                        .putExtra(Intent.EXTRA_PROCESS_TEXT, text),
+                )
+            }
         }, narrow())
 
         if (BuildConfig.DONATE_LINK) content.addView(button(getString(R.string.donate)) { open(KOFI) }, wide(top = 24))
@@ -175,7 +176,7 @@ class MainActivity : Activity() {
                     draw()
                 }
             }
-        }.setNegativeButton(R.string.confirm_cancel, null).show()
+        }.setNegativeButton(R.string.cancel, null).show()
     }
 
     private fun AnkiClient.addNewDeckOrNull(name: String): Long? = runCatching {
@@ -231,7 +232,7 @@ class MainActivity : Activity() {
                 store.mapping = mapping
                 toast(getString(R.string.saved))
             }
-            .setNegativeButton(R.string.confirm_cancel, null)
+            .setNegativeButton(R.string.cancel, null)
             .show()
     }
 
@@ -243,9 +244,6 @@ class MainActivity : Activity() {
         draw()
     }
 
-    private fun installed(packageName: String): Boolean =
-        runCatching { packageManager.getPackageInfo(packageName, 0) }.isSuccess
-
     private fun launch(packageName: String) {
         packageManager.getLaunchIntentForPackage(packageName)?.let { runCatching { startActivity(it) } }
     }
@@ -326,7 +324,6 @@ class MainActivity : Activity() {
     private companion object {
         const val PERMISSION = 1
         const val ANKIDROID_INSTALL = "https://play.google.com/store/apps/details?id=com.ichi2.anki"
-        const val OVERLAY_INSTALL = "https://github.com/equwal/subread-overlay/releases/latest"
         const val DICTIONARY_INSTALL = "https://github.com/equwal/subread-dictionary/releases/latest"
         const val KOFI = "https://ko-fi.com/truex"
     }
diff --git a/app/src/main/kotlin/space/subread/anki/Media.kt b/app/src/main/kotlin/space/subread/anki/Media.kt
index 49ae8b1..96fd7fc 100644
--- a/app/src/main/kotlin/space/subread/anki/Media.kt
+++ b/app/src/main/kotlin/space/subread/anki/Media.kt
@@ -1,9 +1,11 @@
 package space.subread.anki
 
 import android.content.Context
+import android.graphics.Bitmap
 import android.net.Uri
 import android.util.Base64
 import androidx.core.content.FileProvider
+import androidx.core.graphics.scale
 import androidx.core.net.toUri
 import java.io.File
 import java.net.HttpURLConnection
@@ -50,6 +52,22 @@ object Media {
         return out
     }
 
+    /** The longest side of a picture on a card, in pixels. */
+    const val MAX_SIDE = 1600
+
+    /** The picture as a JPEG in [out], at most [MAX_SIDE] pixels on the long side. Null when the write fails. */
+    fun writeJpeg(bitmap: Bitmap, out: File): File? {
+        val longest = maxOf(bitmap.width, bitmap.height)
+        val scaled = if (longest <= MAX_SIDE) bitmap else {
+            val scale = MAX_SIDE.toFloat() / longest
+            bitmap.scale((bitmap.width * scale).toInt(), (bitmap.height * scale).toInt())
+        }
+        return runCatching {
+            out.outputStream().use { scaled.compress(Bitmap.CompressFormat.JPEG, 85, it) }
+            out
+        }.getOrNull()
+    }
+
     /** The bytes of a source. */
     fun bytes(context: Context, source: String): ByteArray? {
         val uri = source.toUri()
diff --git a/app/src/main/kotlin/space/subread/anki/Miner.kt b/app/src/main/kotlin/space/subread/anki/Miner.kt
index 91c5768..7f12cc9 100644
--- a/app/src/main/kotlin/space/subread/anki/Miner.kt
+++ b/app/src/main/kotlin/space/subread/anki/Miner.kt
@@ -1,37 +1,56 @@
 package space.subread.anki
 
 import android.content.Context
-import android.os.SystemClock
+import android.graphics.Bitmap
+import android.icu.text.BreakIterator
+import android.net.Uri
 import space.subread.anki.core.Fields
 import space.subread.anki.core.MediaNames
 import space.subread.anki.core.MineRequest
 import space.subread.anki.core.Note
 import space.subread.anki.core.PlayerReport
+import space.subread.anki.core.Scans
 import space.subread.anki.core.Sentences
 import space.subread.anki.core.Source
 import java.io.File
 
 /**
- * Makes a card from a request: fills what the request does not say from the overlay, the
- * dictionary and the capture, then adds it to AnkiDroid. Two steps, so that the user can be
- * shown the card in between: [prepare], then [add].
+ * Makes cards. [grab] keeps the media of the moment a text comes in: the picture of the
+ * screen and the sound of the sentence. [add] puts one card in AnkiDroid with that media and
+ * the term that the user chose in the pop-up.
  */
 class Miner(private val context: Context) {
 
     private val store = Store(context)
 
-    /** A card ready to add. */
-    class Plan(
+    /** The media of one text, taken when it came in. One pop-up can add several cards with it. */
+    class Grab(
         val request: MineRequest,
-        /** The word as the user selected it, or null for a sentence card. */
-        val selection: String?,
-        val sentence: String?,
-        /** The terms of the dictionary for the selection, the longest first. */
-        val entries: List<DictionaryClient.Entry>,
-        val sentenceAudio: File?,
-        val image: File?,
-        /** The stem of the media files of this card. */
         val stem: String,
+        val picture: File?,
+        val sentenceAudio: File?,
+        /** How long the sentence sound is, in milliseconds, or null without one. */
+        val soundMs: Long?,
+        /** Where the text is from: the sender, the player, or the app that sent it. */
+        val source: String,
+    ) {
+        // The names in the media folder of AnkiDroid, once the first card copied the files.
+        internal var ankiPicture: String? = null
+        internal var ankiSound: String? = null
+    }
+
+    /** The parts of one card that the pop-up chose. */
+    data class Card(
+        val expression: String,
+        val reading: String,
+        val definition: String,
+        val sentence: String,
+        /** The word as it is in the sentence, for the bold. */
+        val selection: String,
+        val pitch: String,
+        val frequency: String,
+        /** Where the dictionary gives the audio of the word, or null. */
+        val wordAudio: Uri?,
     )
 
     sealed class Result {
@@ -41,93 +60,90 @@ class Miner(private val context: Context) {
         class Failed(val why: String) : Result()
     }
 
-    /** Gathers everything for the card. Slow: the sound is encoded, a URL is fetched. Not on the UI thread. */
-    fun prepare(request: MineRequest): Plan {
+    /**
+     * The media of the moment the text came in. [shot] is the screen from before the pop-up
+     * drew. [line] is the overlay when the text is its subtitle line: then the sound is that
+     * line, cut on the clock of the player. [grabbedAt] is `SystemClock.elapsedRealtimeNanos()`
+     * of the moment. Slow: not on the UI thread.
+     */
+    fun grab(request: MineRequest, shot: Bitmap?, grabbedAt: Long, line: OverlayClient.Now?, source: String): Grab {
         Media.cleanOld(context)
-        var selection = request.expression?.trim()?.takeIf { it.isNotEmpty() }
-        var sentence = request.sentence?.trim()?.takeIf { it.isNotEmpty() }
-        // A whole sentence in the selection, and no sentence given: a sentence card.
-        if (selection != null && sentence == null && looksLikeSentence(selection)) {
-            sentence = selection
-            selection = null
-        }
-        val overlay = OverlayClient.now(context)
-        val line = overlay?.line
-        if (sentence == null && line != null && (selection == null || line.text.contains(selection))) sentence = line.text
-        val stamp = System.currentTimeMillis()
-        val stem = MediaNames.stem(selection ?: sentence ?: "card", stamp)
-        val entries = if (selection != null && request.definition == null) DictionaryClient.lookup(context, selection) else emptyList()
-        val sentenceAudio = sentenceAudio(request, overlay, stem)
-        val image = request.image?.let { Media.fetch(context, it, stem, Media.Kind.IMAGE) }
-            ?: CaptureService.instance?.screenshot(Media.file(context, "$stem.jpg"))
-        return Plan(request, selection, sentence, entries, sentenceAudio, image, stem)
+        val stem = MediaNames.stem(request.expression ?: request.sentence ?: "card", System.currentTimeMillis())
+        val picture = request.image?.let { Media.fetch(context, it, stem, Media.Kind.IMAGE) }
+            ?: shot?.let { Media.writeJpeg(it, Media.file(context, "$stem.jpg")) }
+        val (sound, ms) = sentenceAudio(request, line, stem, grabbedAt)
+        return Grab(request, stem, picture, sound, ms, source)
     }
 
     /**
      * The sound of the sentence: cut from the file the request names, or fetched whole, or
-     * taken from the capture. From the capture, the clip is the subtitle line of now, on the
-     * clock of the player; without a line, the last seconds before the tap.
+     * taken from the capture. From the capture, the clip is the subtitle line, on the clock of
+     * the player; without a line, the last seconds before the text came in.
      */
-    private fun sentenceAudio(request: MineRequest, overlay: OverlayClient.Now?, stem: String): File? {
+    private fun sentenceAudio(request: MineRequest, line: OverlayClient.Now?, stem: String, grabbedAt: Long): Pair<File?, Long?> {
         val audio = request.audio
         if (audio != null) {
             val start = request.audioStartMs
             val end = request.audioEndMs
-            return if (start != null && end != null) AudioCut.cut(context, audio, start, end, Media.file(context, "$stem.m4a"))
-            else Media.fetch(context, audio, "${stem}_sentence", Media.Kind.AUDIO)
+            return if (start != null && end != null) {
+                AudioCut.cut(context, audio, start, end, Media.file(context, "$stem.m4a")) to (end - start)
+            } else {
+                Media.fetch(context, audio, "${stem}_sentence", Media.Kind.AUDIO) to null
+            }
         }
-        val capture = CaptureService.instance ?: return null
-        val now = SystemClock.elapsedRealtimeNanos()
-        val line = overlay?.line
+        val capture = CaptureService.instance ?: return null to null
         // A sender that gives the position of its player, and no overlay: the tap is the position.
-        val report = overlay?.report ?: request.positionMs?.let { PlayerReport(it, now, 1.0, false) }
-        val cueStart = request.cueStartMs ?: line?.let { it.startMs - it.offsetMs }
-        val cueEnd = request.cueEndMs ?: line?.let { it.endMs - it.offsetMs }
+        val report = line?.report ?: request.positionMs?.let { PlayerReport(it, grabbedAt, 1.0, false) }
+        val cue = line?.line
+        val cueStart = request.cueStartMs ?: cue?.let { it.startMs - it.offsetMs }
+        val cueEnd = request.cueEndMs ?: cue?.let { it.endMs - it.offsetMs }
         val range = if (report != null && cueStart != null && cueEnd != null && cueEnd > cueStart) {
             report.wallRange(cueStart, cueEnd, store.padMs.toLong())
         } else {
-            (now - LAST_SECONDS * 1_000_000_000L)..now
+            (grabbedAt - LAST_SECONDS * 1_000_000_000L)..grabbedAt
         }
-        return capture.clip(range.first, range.last, Media.file(context, "$stem.m4a"))
+        val clip = capture.clip(range.first, range.last, Media.file(context, "$stem.m4a"))
+        return clip to clip?.let { (range.last - range.first) / 1_000_000L }
     }
 
-    /** Adds the card to AnkiDroid, with [entry] as the term. Not on the UI thread. */
-    fun add(plan: Plan, entry: DictionaryClient.Entry?): Result {
+    /** Adds one card to AnkiDroid. Not on the UI thread. */
+    fun add(grab: Grab, card: Card): Result {
         val anki = AnkiClient(context)
         val deckId = anki.ensureDeck()
         val modelId = anki.ensureModel(deckId)
         val fields = anki.fields(modelId)
         if (fields.isEmpty()) return Result.Failed("the note type has no field")
         val mapping = anki.mapping(modelId, fields)
-        val request = plan.request
-        val expression = entry?.expression ?: plan.selection ?: ""
-        if (store.skipDuplicates && expression.isNotEmpty() && mapping[fields.first()] == Source.EXPRESSION && anki.isDuplicate(modelId, expression)) {
-            return Result.Duplicate(expression)
+        if (store.skipDuplicates && card.expression.isNotEmpty() && mapping[fields.first()] == Source.EXPRESSION &&
+            anki.isDuplicate(modelId, card.expression)
+        ) {
+            return Result.Duplicate(card.expression)
         }
-        val sentenceAudio = plan.sentenceAudio?.let { anki.addMedia(it, "audio") }.orEmpty()
-        val image = plan.image?.let { anki.addMedia(it, "image") }.orEmpty()
-        val wordAudioFile = request.wordAudio?.let { Media.fetch(context, it, "${plan.stem}_word", Media.Kind.AUDIO) }
-            ?: entry?.audio?.let { ask -> DictionaryClient.audio(context, ask)?.let { Media.write(context, it, "${plan.stem}_word", Media.Kind.AUDIO) } }
+        val sound = grab.ankiSound ?: grab.sentenceAudio?.let { anki.addMedia(it, "audio") }?.also { grab.ankiSound = it }
+        val picture = grab.ankiPicture ?: grab.picture?.let { anki.addMedia(it, "image") }?.also { grab.ankiPicture = it }
+        val stem = MediaNames.stem(card.expression.ifEmpty { "card" }, System.currentTimeMillis())
+        val wordAudioFile = grab.request.wordAudio?.let { Media.fetch(context, it, "${stem}_word", Media.Kind.AUDIO) }
+            ?: card.wordAudio?.let { ask -> DictionaryClient.audio(context, ask)?.let { Media.write(context, it, "${stem}_word", Media.Kind.AUDIO) } }
         val wordAudio = wordAudioFile?.let { anki.addMedia(it, "audio") }.orEmpty()
         val note = Note(
-            expression = expression,
-            reading = request.reading ?: entry?.reading.orEmpty(),
-            definition = request.definition ?: entry?.glossary.orEmpty(),
-            sentence = plan.sentence.orEmpty(),
-            sentenceAudioFile = sentenceAudio,
+            expression = card.expression,
+            reading = card.reading,
+            definition = card.definition,
+            sentence = card.sentence,
+            sentenceAudioFile = sound.orEmpty(),
             wordAudioFile = wordAudio,
-            imageFile = image,
-            source = request.source.orEmpty(),
-            pitch = request.pitch ?: entry?.pitch.orEmpty(),
-            frequency = request.frequency ?: entry?.frequency.orEmpty(),
-            selection = plan.selection.orEmpty(),
+            imageFile = picture.orEmpty(),
+            source = grab.source,
+            pitch = card.pitch,
+            frequency = card.frequency,
+            selection = card.selection,
         )
         val values = Fields.render(note, fields, mapping)
-        val tags = (store.tags.split(' ') + request.tags).map { it.trim() }.filter { it.isNotEmpty() }.toSet()
+        val tags = (store.tags.split(' ') + grab.request.tags).map { it.trim() }.filter { it.isNotEmpty() }.toSet()
         val noteId = anki.addNote(modelId, deckId, values, tags) ?: return Result.Failed("AnkiDroid refused the note")
         store.lastNoteId = noteId
         store.lastNoteAt = System.currentTimeMillis()
-        return Result.Added(noteId, expression)
+        return Result.Added(noteId, card.expression)
     }
 
     /** A picture shared on its own goes onto the last card of the hour. Not on the UI thread. */
@@ -152,7 +168,7 @@ class Miner(private val context: Context) {
     }
 
     companion object {
-        /** Without a subtitle line, the clip is this much sound before the tap. */
+        /** Without a subtitle line, the clip is this much sound before the text came in. */
         const val LAST_SECONDS = 8
 
         /** The longest selection that is a word. Longer, or with the end of a sentence in it, is a sentence. */
@@ -161,5 +177,41 @@ class Miner(private val context: Context) {
         /** True for a selection that is a sentence, not a word. */
         fun looksLikeSentence(text: String): Boolean =
             text.length > MAX_WORD || text.any { Sentences.isTerminator(it) } || text.trim().split(Regex("\\s+")).size > 3
+
+        /**
+         * The card for a term of the dictionary, or for the word alone when [entry] is null. The
+         * word starts at [start] of [text] and is [length] characters long; -1 when the word is
+         * not in the text. What the request gives wins over the dictionary.
+         */
+        fun card(request: MineRequest, text: String, start: Int, length: Int, entry: DictionaryClient.Entry?): Card {
+            val inText = start >= 0 && length > 0 && start + length <= text.length
+            val selection = if (inText) text.substring(start, start + length) else ""
+            return Card(
+                expression = entry?.expression ?: selection.ifEmpty { request.expression?.trim().orEmpty() },
+                reading = request.reading ?: entry?.reading.orEmpty(),
+                definition = request.definition ?: entry?.glossary.orEmpty(),
+                sentence = if (inText) Scans.sentence(text, start, length) else request.sentence?.trim().orEmpty(),
+                selection = selection,
+                pitch = request.pitch ?: entry?.pitch.orEmpty(),
+                frequency = request.frequency ?: entry?.frequency.orEmpty(),
+                wordAudio = entry?.audio,
+            )
+        }
+
+        /**
+         * The word at [index] of [text], without a dictionary. A text that the user [selected]
+         * as it is, and that is no sentence, is one word: the user chose its ends. Else the word
+         * boundaries of ICU, which also split Japanese and Chinese.
+         */
+        fun wordAt(text: String, index: Int, selected: Boolean): IntRange {
+            if (text.isEmpty()) return IntRange.EMPTY
+            if (selected && !looksLikeSentence(text)) return text.indices
+            val at = index.coerceIn(0, text.length - 1)
+            val words = BreakIterator.getWordInstance()
+            words.setText(text)
+            val end = words.following(at).let { if (it == BreakIterator.DONE) text.length else it }
+            val start = words.preceding(end).let { if (it == BreakIterator.DONE) 0 else it }
+            return start until end
+        }
     }
 }
diff --git a/app/src/main/kotlin/space/subread/anki/Store.kt b/app/src/main/kotlin/space/subread/anki/Store.kt
index 4cff2aa..33d75c0 100644
--- a/app/src/main/kotlin/space/subread/anki/Store.kt
+++ b/app/src/main/kotlin/space/subread/anki/Store.kt
@@ -32,21 +32,19 @@ class Store(context: Context) {
         get() = prefs.getString("tags", "subread") ?: "subread"
         set(value) = prefs.edit { putString("tags", value.trim()) }
 
-    /** True: the app shows the card before it adds it. False: one tap adds. */
-    var confirm: Boolean
-        get() = prefs.getBoolean("confirm", false)
-        set(value) = prefs.edit { putBoolean("confirm", value) }
+    /**
+     * False: a selected text opens the pop-up, and "+ Anki" adds the term. True: the first
+     * term at the start of the selection goes on a card at once, with no pop-up.
+     */
+    var addAtOnce: Boolean
+        get() = prefs.getBoolean("add_at_once", false)
+        set(value) = prefs.edit { putBoolean("add_at_once", value) }
 
     /** True: a word that is in the note type already is not added again. */
     var skipDuplicates: Boolean
         get() = prefs.getBoolean("skip_duplicates", true)
         set(value) = prefs.edit { putBoolean("skip_duplicates", value) }
 
-    /** True: after the card, the pop-up of SubRead Dictionary opens for the word. */
-    var openDictionary: Boolean
-        get() = prefs.getBoolean("open_dictionary", false)
-        set(value) = prefs.edit { putBoolean("open_dictionary", value) }
-
     /** Sound before and after the subtitle line in the clip, in milliseconds. */
     var padMs: Int
         get() = prefs.getInt("pad_ms", 250)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index c1e56ef..42f57f0 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,11 +1,11 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
     <string name="app_name">SubRead Anki</string>
-    <string name="add_label">Anki</string>
+    <string name="add_label">Anki card</string>
     <string name="tile_label">Anki capture</string>
 
-    <string name="about">One tap makes an Anki card: the word, its reading and definition, the sentence it is in, the sound of that sentence, and a picture of the screen. From SubRead Overlay, from the text selection menu of any app, from the share sheet, or from a web page.</string>
-    <string name="privacy">Nothing leaves the device. The app talks to AnkiDroid, SubRead Overlay and SubRead Dictionary on the device. It opens a URL only when a sender gives one for an audio or a picture.</string>
+    <string name="about">Select a text in any app and choose "Anki card" in the menu. A pop-up shows the terms at the word, the same as a dictionary. A tap on another character moves the word. Tap "+ Anki": the card gets the word, its reading and definition, the sentence, a picture of the screen and the sound of the sentence.</string>
+    <string name="privacy">Nothing leaves the device. The app talks to AnkiDroid and SubRead Dictionary on the device. It opens a URL only when a sender gives one for an audio or a picture.</string>
 
     <string name="step_anki">1. AnkiDroid</string>
     <string name="anki_missing">AnkiDroid is not installed. The app adds the cards to it.</string>
@@ -27,19 +27,16 @@
     <string name="fields_why">What goes into each field of the note type. The app guesses from the names; Lapis and the basic note types work as they are.</string>
     <string name="field_source">%1$s ← %2$s</string>
 
-    <string name="step_subread">3. SubRead Overlay and Dictionary</string>
-    <string name="overlay_ok">SubRead Overlay is installed. In its settings, choose "Anki" as the dictionary: a tap on a word of the subtitle line makes the card, with the line as the sentence and its sound.</string>
-    <string name="overlay_missing">SubRead Overlay is not installed. With it, a tap on a word of the subtitle line makes the card, with the line as the sentence and its sound.</string>
-    <string name="dictionary_ok">SubRead Dictionary is installed: it gives the reading, the definition, the pitch, the frequency and the word audio.</string>
+    <string name="step_dictionary">3. Dictionary</string>
+    <string name="dictionary_ok">SubRead Dictionary gives the terms of the pop-up: the reading, the definition and the word audio.</string>
     <string name="dictionary_old">SubRead Dictionary is installed, but this version does not answer other apps. Update it for the definitions.</string>
-    <string name="dictionary_missing">SubRead Dictionary is not installed. Without it, a card has no definition unless the sender gives one.</string>
-    <string name="open_overlay">Open SubRead Overlay</string>
+    <string name="dictionary_missing">SubRead Dictionary is not installed. Without it, the pop-up has no definitions: the word goes on the card as it is.</string>
     <string name="open_dictionary">Open SubRead Dictionary</string>
-    <string name="install_overlay">Install SubRead Overlay</string>
     <string name="install_dictionary">Install SubRead Dictionary</string>
 
-    <string name="step_capture">4. Capture</string>
-    <string name="capture_why">The sound of the player and a picture of the screen go on the card while the capture is on. Android asks each time it starts. The quick settings have a tile for it. A player that hides its sound (DRM) gives no sound.</string>
+    <string name="step_capture">4. Picture and sound</string>
+    <string name="capture_why">While the capture is on, each card gets a picture of the screen and the sound of the last seconds before the text was selected: the audiobook that you listen to while you read. A clip of silence is left out. Android asks each time the capture starts. The quick settings have a tile for it.</string>
+    <string name="capture_overlay">With SubRead Overlay, a tap on a subtitle word shows its line in the pop-up, and the sound of that line goes on the card.</string>
     <string name="capture_no_audio">This Android version captures the screen only; the sound of other apps needs Android 10.</string>
     <string name="capture_start">Start the capture</string>
     <string name="capture_stop">Stop the capture</string>
@@ -47,38 +44,44 @@
     <string name="capture_channel">Capture</string>
     <string name="capture_denied">Android did not allow the capture.</string>
     <string name="capture_no_record">The capture needs the microphone permission of Android, for the sound of the player. No microphone is recorded.</string>
-    <string name="pad">Sound before and after the line, in milliseconds</string>
+    <string name="pad">Sound before and after a subtitle line, in milliseconds</string>
 
     <string name="step_options">5. Options</string>
-    <string name="confirm_on">Ask before adding: on</string>
-    <string name="confirm_off">Ask before adding: off (one tap adds)</string>
+    <string name="popup_on">A selected text: show the pop-up</string>
+    <string name="popup_off">A selected text: add the first term at once, with no pop-up</string>
     <string name="duplicates_skip">A word that is in the deck already: skip</string>
     <string name="duplicates_add">A word that is in the deck already: add again</string>
-    <string name="open_dictionary_on">After the card, open SubRead Dictionary: on</string>
-    <string name="open_dictionary_off">After the card, open SubRead Dictionary: off</string>
     <string name="tags">Tags, separated by spaces</string>
     <string name="save">Save</string>
     <string name="saved">Saved.</string>
     <string name="toggle">Change</string>
 
     <string name="step_try">6. Try it</string>
-    <string name="try_hint">A word, or a sentence</string>
-    <string name="try_add">Add a card</string>
+    <string name="try_hint">A sentence</string>
+    <string name="try_look_up">Look up</string>
 
     <string name="donate">Support the app on Ko-fi</string>
 
+    <string name="settings">Settings</string>
+    <string name="close">Close</string>
+    <string name="add_card">+ Anki</string>
+    <string name="added_short">✓ Added</string>
+    <string name="in_anki">✓ In Anki</string>
+    <string name="no_term">No term here. Tap another character, or add the word as it is.</string>
+    <string name="no_dictionary">No dictionary: install SubRead Dictionary for the reading and the definition. The word goes on the card as it is.</string>
+    <string name="no_audio">No audio for this term.</string>
+    <string name="status_picture">Picture ✓</string>
+    <string name="status_no_picture">No picture</string>
+    <string name="status_sound">Sound ✓</string>
+    <string name="status_sound_seconds">Sound %1$s s ✓</string>
+    <string name="status_no_sound">No sound</string>
+    <string name="status_capture_off">Capture off: no picture, no sound</string>
+
     <string name="no_text">The request is empty: no word and no sentence.</string>
     <string name="added">Added to Anki: %1$s</string>
-    <string name="added_sentence">Added the sentence to Anki</string>
     <string name="duplicate">Already in Anki: %1$s</string>
     <string name="failed">Anki did not take the card: %1$s</string>
-    <string name="confirm_title">Add to Anki</string>
-    <string name="confirm_add">Add</string>
-    <string name="confirm_cancel">Cancel</string>
-    <string name="confirm_sentence_only">Sentence card, no word</string>
-    <string name="confirm_media">Sound: %1$s   Picture: %2$s   Word audio: %3$s</string>
-    <string name="yes">yes</string>
-    <string name="no">no</string>
+    <string name="cancel">Cancel</string>
     <string name="picture_attached">Picture added to the last card</string>
     <string name="picture_no_card">No card of the last hour to add the picture to</string>
 </resources>
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..bf97ffc
--- /dev/null
+++ b/app/src/main/res/values/styles.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <!-- The pop-up: a floating panel over the app that sent the text. A tap outside closes it. The same as SubRead Dictionary. -->
+    <style name="Popup" parent="@android:style/Theme.DeviceDefault.Light.Dialog.NoActionBar">
+        <item name="android:windowIsFloating">true</item>
+        <item name="android:windowNoTitle">true</item>
+        <item name="android:windowBackground">@android:color/white</item>
+        <item name="android:backgroundDimEnabled">false</item>
+        <item name="android:windowCloseOnTouchOutside">true</item>
+        <item name="android:windowAnimationStyle">@null</item>
+    </style>
+</resources>
diff --git a/core/src/main/kotlin/space/subread/anki/core/Loudness.kt b/core/src/main/kotlin/space/subread/anki/core/Loudness.kt
new file mode 100644
index 0000000..0be3774
--- /dev/null
+++ b/core/src/main/kotlin/space/subread/anki/core/Loudness.kt
@@ -0,0 +1,21 @@
+package space.subread.anki.core
+
+import kotlin.math.sqrt
+
+/** How loud a clip of 16-bit sound is. A clip of silence does not go on a card. */
+object Loudness {
+
+    /** Below this root mean square, a clip is silence: a reader app plays no sound. */
+    const val SILENCE = 150.0
+
+    /** The root mean square of the samples, from 0 to 32768. 0 for no samples. */
+    fun rms(samples: ShortArray): Double {
+        if (samples.isEmpty()) return 0.0
+        var sum = 0.0
+        for (sample in samples) sum += sample.toDouble() * sample
+        return sqrt(sum / samples.size)
+    }
+
+    /** True when the clip has nothing to hear. */
+    fun isSilent(samples: ShortArray, threshold: Double = SILENCE): Boolean = rms(samples) < threshold
+}
diff --git a/core/src/main/kotlin/space/subread/anki/core/Scans.kt b/core/src/main/kotlin/space/subread/anki/core/Scans.kt
new file mode 100644
index 0000000..d4399b8
--- /dev/null
+++ b/core/src/main/kotlin/space/subread/anki/core/Scans.kt
@@ -0,0 +1,52 @@
+package space.subread.anki.core
+
+/**
+ * The text that the pop-up shows, and where the word starts in it. The pop-up looks up the
+ * word at [offset]. A tap on another character moves the offset there.
+ */
+data class Scan(
+    val text: String,
+    /** Where the word starts in [text]. -1: the word is not in the text, so [word] is looked up alone. */
+    val offset: Int,
+    /** The word that the sender gave, or null. */
+    val word: String?,
+    /** True when [text] is the subtitle line of SubRead Overlay. */
+    val fromLine: Boolean,
+)
+
+/** Chooses the text of the pop-up, and the sentence of a card in it. */
+object Scans {
+
+    /**
+     * The text of the pop-up for a request:
+     * - a sentence from the sender, at the word when the word is in it;
+     * - else the subtitle line of the overlay that holds the selection, at the selection;
+     * - else the selection itself, at its start.
+     *
+     * Null when there is neither a selection nor a sentence.
+     */
+    fun resolve(selection: String?, sentence: String?, line: String?): Scan? {
+        val word = selection?.trim().orEmpty()
+        val given = sentence?.trim().orEmpty()
+        if (given.isNotEmpty()) {
+            return if (word.isEmpty()) Scan(given, 0, null, false) else Scan(given, given.indexOf(word), word, false)
+        }
+        if (word.isEmpty()) return null
+        val subtitle = line?.trim().orEmpty()
+        val at = if (subtitle.isEmpty()) -1 else subtitle.indexOf(word)
+        if (at >= 0) return Scan(subtitle, at, word, true)
+        return Scan(word, 0, word, false)
+    }
+
+    /**
+     * The sentence of the card for a word that starts at [start] and is [length] characters
+     * long: the sentence of [text] around it. Empty when the text is the word alone: a word is
+     * no sentence.
+     */
+    fun sentence(text: String, start: Int, length: Int): String {
+        if (text.isBlank()) return ""
+        val at = start.coerceIn(0, text.length - 1)
+        val found = text.substring(Sentences.rangeAround(text, at)).trim()
+        return if (found.length <= length) "" else found
+    }
+}
diff --git a/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt b/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt
index 2aa469d..d9f078d 100644
--- a/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt
+++ b/core/src/test/kotlin/space/subread/anki/core/CoreTest.kt
@@ -371,6 +371,76 @@ class FieldsTest {
     }
 }
 
+/** The text of the pop-up and the sentence of a card. */
+class ScansTest {
+
+    private val piece: Arb<String> = text(6, listOf("a", "b", "猫", "ね", "食", "べ", "た", "。", " ", "「", "」"), least = 1)
+
+    @Test
+    fun theWordIsWhereTheScanSaysItIs() = property {
+        checkAll(piece.orNull(0.3), piece.orNull(0.3), piece.orNull(0.3)) { selection, sentence, line ->
+            val scan = Scans.resolve(selection, sentence, line) ?: run {
+                // No scan only when there is nothing to show.
+                assertTrue(selection.isNullOrBlank() && sentence.isNullOrBlank())
+                return@checkAll
+            }
+            assertTrue("empty text for $selection $sentence $line", scan.text.isNotEmpty())
+            val word = scan.word
+            if (word != null && scan.offset >= 0) assertTrue(scan.text.regionMatches(scan.offset, word, 0, word.length))
+            if (scan.offset < 0) assertTrue(word != null && !scan.text.contains(word))
+            if (scan.fromLine) assertEquals(line!!.trim(), scan.text)
+        }
+    }
+
+    @Test
+    fun theSenderSentenceWinsThenTheLineThenTheSelection() {
+        assertEquals(Scan("猫が魚を食べた。", 4, "食べた", false), Scans.resolve("食べた", "猫が魚を食べた。", "犬だ。"))
+        assertEquals(Scan("猫が魚を食べた。", -1, "食べる", false), Scans.resolve("食べる", "猫が魚を食べた。", null))
+        assertEquals(Scan("猫が魚を食べた。", 4, "食べた", true), Scans.resolve("食べた", null, "猫が魚を食べた。"))
+        assertEquals(Scan("食べた", 0, "食べた", false), Scans.resolve("食べた", null, "犬だ。"))
+        assertEquals(Scan("猫が魚を食べた。", 0, null, false), Scans.resolve(null, "猫が魚を食べた。", null))
+        assertNull(Scans.resolve(" ", null, "猫だ。"))
+    }
+
+    @Test
+    fun theSentenceIsInTheText() = property {
+        checkAll(anyText, Arb.int(0..50), Arb.int(0..10)) { text, start, length ->
+            val sentence = Scans.sentence(text, start, length)
+            assertTrue("$sentence is in $text", sentence.isEmpty() || text.contains(sentence))
+            assertTrue(sentence.isEmpty() || sentence.length > length)
+        }
+    }
+
+    @Test
+    fun aWordAloneIsNoSentence() {
+        assertEquals("", Scans.sentence("食べた", 0, 3))
+        assertEquals("猫が魚を食べた。", Scans.sentence("犬だ。猫が魚を食べた。", 7, 3))
+        assertEquals("", Scans.sentence("", 0, 0))
+    }
+}
+
+/** Silence is not sound for a card. */
+class LoudnessTest {
+
+    @Test
+    fun aConstantHasItsOwnSizeAsLoudness() = property {
+        checkAll(Arb.int(-32767..32767), Arb.int(1..500)) { value, size ->
+            val rms = Loudness.rms(ShortArray(size) { value.toShort() })
+            assertEquals(kotlin.math.abs(value).toDouble(), rms, 1e-6)
+        }
+    }
+
+    @Test
+    fun speechIsNotSilenceAndZerosAre() {
+        val speech = ShortArray(44_100) { (3000 * kotlin.math.sin(2 * Math.PI * 220 * it / 44_100.0)).toInt().toShort() }
+        assertFalse(Loudness.isSilent(speech))
+        assertTrue(Loudness.isSilent(ShortArray(44_100)))
+        assertTrue(Loudness.isSilent(ShortArray(0)))
+        // A noise floor of a few steps is silence.
+        assertTrue(Loudness.isSilent(ShortArray(1000) { ((it % 7) - 3).toShort() }))
+    }
+}
+
 /** Names of media files. */
 class MediaNamesTest {
 
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 189a7ce..6ef19d6 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -1,13 +1,13 @@
-SubRead Anki makes an Anki card with one tap: the word, its reading and definition, the sentence it is in, the sound of that sentence, and a picture of the screen. The card goes into AnkiDroid on the device. It is made for people who read and listen in a language that they learn.
+SubRead Anki is a pop-up for Anki cards, used the same way as a dictionary. Select a text in any app and choose "Anki card" in the text selection menu. The pop-up shows the text and the terms at the word. Tap "+ Anki": the card goes into AnkiDroid with the word, its reading and definition, the sentence around the word, a picture of the screen and the sound of the sentence. It is made for people who read and listen in a language that they learn.
 
 <b>What it does</b>
 
-* A tap on a word of the subtitle line of SubRead Overlay makes the card: the line is the sentence, and its times cut the sound of the audiobook.
-* "Anki" in the text selection menu of any app: a reader, a browser, an OCR app. Select a word, tap once.
-* The share sheet: a text, or a picture for the last card.
+* Works in each app that lets you select a text: readers, browsers, OCR apps.
+* A tap on a character of the text moves the word there, so one selection gives cards for several words.
+* The reading, the definition and the word audio come from SubRead Dictionary.
+* The capture keeps the sound of the device and the newest picture of the screen while it is on. The card gets the screen of the moment you selected the text, and the sound of the seconds before it: the audiobook that plays while you read. A tile in the quick settings starts and stops it.
+* With SubRead Overlay, a subtitle word shows its whole line, and the card gets the sound of that line.
 * Another app, or a link on a web page, can send a whole card: word, sentence, audio and its times, picture.
-* The reading, the definition, the pitch accent, the frequency and the word audio come from SubRead Dictionary.
-* The capture keeps the last minute and a half of the sound of the device and the last picture of the screen, while it is on. A tile in the quick settings starts and stops it.
 * Works with the Lapis note type, the basic note types, or its own.
 * Black on white and nothing that moves: usable on an e-ink screen.
 
diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt
index 92d9f4e..0ea460c 100644
--- a/fastlane/metadata/android/en-US/short_description.txt
+++ b/fastlane/metadata/android/en-US/short_description.txt
@@ -1 +1 @@
-One tap makes an Anki card: word, definition, sentence, its sound, a picture.
\ No newline at end of file
+Select a text in any app, tap a word, tap + Anki: a card with sentence, picture, sound.
\ No newline at end of file