commit 0b49cb50148e6653106e9e591f4c5519831de763 equwal <truex@equwal.com> 2026-09-25 02:14:09 -0700 Add an "Anki" button under a selected word With SubRead Anki installed, the selection row gets an "Anki" button. One tap sends the word, the subtitle line as the sentence, and the title of the media to SubRead Anki, which makes the card in AnkiDroid. The panel hides for a moment, so that the screenshot of SubRead Anki shows the player and not the panel. The Follower gives the title and the package of the player for this.
README.md | 10 +++++++ app/src/main/AndroidManifest.xml | 3 +- app/src/main/kotlin/space/subread/overlay/Anki.kt | 32 ++++++++++++++++++++++ .../main/kotlin/space/subread/overlay/Follower.kt | 9 ++++++ .../kotlin/space/subread/overlay/MainActivity.kt | 7 +++++ .../kotlin/space/subread/overlay/MediaListener.kt | 30 +++++++++++++++++++- .../kotlin/space/subread/overlay/OverlayView.kt | 14 ++++++++-- app/src/main/res/values/strings.xml | 4 +++ 8 files changed, 105 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md index ccfe705..3f13143 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,16 @@ starts to play, and the panel holds its line in a pause. Set the timing with The app does not use an accessibility service, and will not. Players that hide their position (Netflix, some DRM players) are not supported. +## Anki cards + +With [SubRead Anki](https://github.com/equwal/subread-anki) installed, the +selection row has an "Anki" button. One tap makes a card in AnkiDroid: the +word, its reading and definition (from SubRead Dictionary), the line as the +sentence, a screenshot of the player, the word audio and the line read by the +voice of the device. The panel hides for a moment so that the screenshot shows +the player. SubRead Anki has its own optional capture service; this app stays +without one. + A book in many audio files: the player reports the position in the current file, and the subtitle file has one clock for the whole book. Shift the subtitles with the timing row at the start of each file. One `.m4b` for one diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7ac6975..c1be2b6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,9 +5,10 @@ <!-- The subtitle line is drawn over the media player. No network permission: nothing leaves the device. --> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> - <!-- The apps that can take a word: dictionaries, translators, flash card apps. And the SubRead app, to open it when it is installed. --> + <!-- The apps that can take a word: dictionaries, translators, flash card apps. And the SubRead apps, to open them when they are installed. --> <queries> <package android:name="space.subread.app" /> + <package android:name="space.subread.anki" /> <intent> <action android:name="android.intent.action.PROCESS_TEXT" /> <data android:mimeType="text/plain" /> diff --git a/app/src/main/kotlin/space/subread/overlay/Anki.kt b/app/src/main/kotlin/space/subread/overlay/Anki.kt new file mode 100644 index 0000000..1ad2ffd --- /dev/null +++ b/app/src/main/kotlin/space/subread/overlay/Anki.kt @@ -0,0 +1,32 @@ +package space.subread.overlay + +import android.content.Context +import android.content.Intent + +/** + * Sends a card to SubRead Anki: the word and the subtitle line it is in. The strings are the + * intent contract of that app, see its docs/intent-api.md. SubRead Anki looks the word up in + * SubRead Dictionary, takes a screenshot of the player, reads the line with the voice of the + * device, and puts the card in AnkiDroid. + */ +object Anki { + const val PACKAGE = "space.subread.anki" + const val INSTALL = "https://github.com/equwal/subread-anki/releases/latest" + private const val ACTION_ADD = "space.subread.anki.action.ADD" + private const val EXTRA_WORD = "space.subread.anki.extra.WORD" + private const val EXTRA_SENTENCE = "space.subread.anki.extra.SENTENCE" + private const val EXTRA_SOURCE = "space.subread.anki.extra.SOURCE" + private const val EXTRA_SCREENSHOT = "space.subread.anki.extra.SCREENSHOT" + + fun installed(context: Context): Boolean = + context.packageManager.resolveActivity(Intent(ACTION_ADD).setPackage(PACKAGE), 0) != null + + /** The intent for one card. The panel is not an activity, so the intent starts a new task. */ + fun intent(word: String, line: String, source: String): Intent = Intent(ACTION_ADD) + .setPackage(PACKAGE) + .putExtra(EXTRA_WORD, word) + .putExtra(EXTRA_SENTENCE, line) + .putExtra(EXTRA_SOURCE, source) + .putExtra(EXTRA_SCREENSHOT, true) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) +} diff --git a/app/src/main/kotlin/space/subread/overlay/Follower.kt b/app/src/main/kotlin/space/subread/overlay/Follower.kt index 630f1fd..6d78ad0 100644 --- a/app/src/main/kotlin/space/subread/overlay/Follower.kt +++ b/app/src/main/kotlin/space/subread/overlay/Follower.kt @@ -1,5 +1,6 @@ package space.subread.overlay +import android.media.MediaMetadata import android.media.session.MediaController import android.media.session.PlaybackState import android.os.Handler @@ -70,6 +71,14 @@ class Follower( val playing: Boolean get() = controller?.playbackState?.state == PlaybackState.STATE_PLAYING + /** The title that the player gives its media, or null. */ + val title: String? + get() = controller?.metadata?.getString(MediaMetadata.METADATA_KEY_TITLE)?.takeIf { it.isNotBlank() } + + /** The package of the player, or null. */ + val player: String? + get() = controller?.packageName + /** Pauses the player. True when it played. */ fun pause(): Boolean { if (!playing) return false diff --git a/app/src/main/kotlin/space/subread/overlay/MainActivity.kt b/app/src/main/kotlin/space/subread/overlay/MainActivity.kt index 8b8b2ae..98cdb9b 100644 --- a/app/src/main/kotlin/space/subread/overlay/MainActivity.kt +++ b/app/src/main/kotlin/space/subread/overlay/MainActivity.kt @@ -102,6 +102,13 @@ class MainActivity : Activity() { } else { content.addView(button(getString(R.string.install_subread)) { open(SUBREAD_INSTALL) }, wide()) } + note(getString(R.string.anki_note), top = 24) + val anki = packageManager.getLaunchIntentForPackage(Anki.PACKAGE) + if (anki != null) { + content.addView(button(getString(R.string.open_anki)) { runCatching { startActivity(anki) } }, wide()) + } else { + content.addView(button(getString(R.string.install_anki)) { open(Anki.INSTALL) }, wide()) + } if (BuildConfig.DONATE_LINK) content.addView(button(getString(R.string.donate)) { open(KOFI) }, wide()) } diff --git a/app/src/main/kotlin/space/subread/overlay/MediaListener.kt b/app/src/main/kotlin/space/subread/overlay/MediaListener.kt index 6759612..d5a52c7 100644 --- a/app/src/main/kotlin/space/subread/overlay/MediaListener.kt +++ b/app/src/main/kotlin/space/subread/overlay/MediaListener.kt @@ -9,6 +9,7 @@ import android.os.Looper import android.provider.Settings import android.service.notification.NotificationListenerService import android.view.Gravity +import android.view.View import android.view.WindowManager import android.widget.Toast import space.subread.overlay.core.CueIndex @@ -39,6 +40,9 @@ class MediaListener : NotificationListenerService(), OverlayView.Events { /** True when a live line came while a word was selected: the panel shows it after the selection. */ private var livePending = false + + /** The line that the panel shows now, without the lines around it. For the Anki card. */ + private var currentLine = "" private val liveTimeout = Runnable { endLive() } private val params = WindowManager.LayoutParams( WindowManager.LayoutParams.MATCH_PARENT, @@ -79,7 +83,7 @@ class MediaListener : NotificationListenerService(), OverlayView.Events { if (panel == null) { params.x = store.x params.y = store.y - panel = OverlayView(this, this).also { getSystemService(WindowManager::class.java).addView(it, params) } + panel = OverlayView(this, this, Anki.installed(this)).also { getSystemService(WindowManager::class.java).addView(it, params) } } reload() return true @@ -166,6 +170,7 @@ class MediaListener : NotificationListenerService(), OverlayView.Events { } livePending = false val now = if (live.partial) live.now + " …" else live.now + currentLine = live.now if (store.linesAround) view.showLine(now, live.before, null) else view.showLine(now) } @@ -178,6 +183,7 @@ class MediaListener : NotificationListenerService(), OverlayView.Events { view.showPlaying(playing) val cues = follower.index.cues val line = cues.getOrNull(at)?.text + currentLine = line ?: "" when { store.subtitles == null -> view.showStatus(getString(R.string.status_no_file)) cues.isEmpty() -> view.showStatus(getString(R.string.status_empty_file)) @@ -222,6 +228,24 @@ class MediaListener : NotificationListenerService(), OverlayView.Events { runCatching { startActivity(Lookup.share(word)) } } + /** + * The word and the line go to SubRead Anki. The panel hides for a moment, so that the + * screenshot of SubRead Anki shows the player and not the panel. + */ + override fun onAnki(word: String) { + follower.pause() + val view = panel ?: return + val source = follower.title ?: follower.player?.let { pkg -> + runCatching { packageManager.getApplicationLabel(packageManager.getApplicationInfo(pkg, 0)).toString() }.getOrNull() + } ?: "" + val intent = Anki.intent(word, currentLine, source) + view.visibility = View.INVISIBLE + handler.postDelayed({ + runCatching { startActivity(intent) }.onFailure { Toast.makeText(this, R.string.no_anki, Toast.LENGTH_SHORT).show() } + }, ANKI_HIDE_MS) + handler.postDelayed({ panel?.visibility = View.VISIBLE }, ANKI_SHOW_MS) + } + override fun onTogglePlay() { if (!follower.pause()) follower.play() } @@ -245,6 +269,10 @@ class MediaListener : NotificationListenerService(), OverlayView.Events { /** Live lines end on their own this long after the last one. */ const val LIVE_TIMEOUT_MS = 10 * 60_000L + /** The panel hides this long before the card goes, and comes back this long after. */ + const val ANKI_HIDE_MS = 150L + const val ANKI_SHOW_MS = 1800L + /** The listener that the system runs now; null when the user did not allow it. */ @Volatile var instance: MediaListener? = null diff --git a/app/src/main/kotlin/space/subread/overlay/OverlayView.kt b/app/src/main/kotlin/space/subread/overlay/OverlayView.kt index 62fd35f..16ae2d3 100644 --- a/app/src/main/kotlin/space/subread/overlay/OverlayView.kt +++ b/app/src/main/kotlin/space/subread/overlay/OverlayView.kt @@ -25,13 +25,16 @@ import androidx.core.view.isVisible * * A tap on the line selects the word under the finger and pauses the player at once. A drag * makes the selection longer, word by word. When the finger lifts, the selection goes to the - * dictionary. The system text selection is not used: it needs a window with the input focus, + * dictionary. When the dictionary closes, the selection goes away. The system text selection is not used: it needs a window with the input focus, * and then the player below gets no keys. * + * With SubRead Anki installed, the selection row has an "Anki" button: one tap makes a card + * from the word and the line. + * * Black on white and nothing that moves, so that the panel is usable on an e-ink screen. */ @SuppressLint("ViewConstructor", "SetTextI18n") -class OverlayView(context: Context, private val events: Events) : LinearLayout(context) { +class OverlayView(context: Context, private val events: Events, anki: Boolean = false) : LinearLayout(context) { interface Events { fun onDrag(dx: Float, dy: Float) @@ -43,6 +46,8 @@ class OverlayView(context: Context, private val events: Events) : LinearLayout(c fun onLookUp(word: String) /** "Share": the selection goes to an app that the user picks in the share sheet. */ fun onShare(word: String) + /** "Anki": the selection and the line go to SubRead Anki as a card. */ + fun onAnki(word: String) {} /** The play button: pause the player when it plays, else start it. */ fun onTogglePlay() fun onShiftLines(steps: Int) @@ -92,6 +97,7 @@ class OverlayView(context: Context, private val events: Events) : LinearLayout(c addView(top, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) lookUpRow.gravity = Gravity.END + if (anki) lookUpRow.addView(button("Anki", "Make an Anki card") { anki() }, wrap()) lookUpRow.addView(button("Copy", null) { copy() }, wrap()) lookUpRow.addView(button("Share", null) { share() }, wrap()) lookUpRow.visibility = View.GONE @@ -206,6 +212,10 @@ class OverlayView(context: Context, private val events: Events) : LinearLayout(c events.onShare(selected() ?: return) } + private fun anki() { + events.onAnki(selected() ?: return) + } + private fun copy() { val word = selected() ?: return context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("subtitle", word)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ecfbc9e..cdf8ed0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,9 +40,13 @@ <string name="status_no_player">No media player. Start the audiobook or the video.</string> <string name="status_before_first_line">Before the first line.</string> <string name="no_dictionary">No app took the word.</string> + <string name="no_anki">SubRead Anki did not take the card.</string> <string name="make_subtitles">No .srt for your audiobook? The SubRead app makes it from the audiobook and its ebook, on this device.</string> <string name="open_subread">Open SubRead</string> <string name="install_subread">Install SubRead</string> + <string name="anki_note">One tap on "Anki" under a selected word makes a card in AnkiDroid: the word, its reading and definition, the line, a screenshot of the player and the audio. SubRead Anki does this, with SubRead Dictionary for the definition.</string> + <string name="open_anki">Open SubRead Anki</string> + <string name="install_anki">Install SubRead Anki</string> <string name="donate">Support the work on Ko-fi</string> </resources>