Recently Written · git

subread-overlay

Shows the lines of an .srt over any Android media player, in time with it. Tap a word to look it up.

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

Log | Files | Refs


commit 29adf5fccf0b15ce6c6bd18ee65da533e877a2a6
equwal <truex@equwal.com>
2026-09-25 02:14:33 -0700

Give the subtitle line of now to flash card apps

A query of the path "line" of the player provider returns the line for
the position of now, with the report of the player as it came, the
offset of the user, and the lines before and after. SubRead Anki puts the
line on a card and cuts its sound from the times.

The provider reads the subtitle file itself, so the row comes also while
the panel is hidden. The file is read again when the user chooses
another one or its date changes.

 README.md                                          | 25 ++++++
 .../space/subread/overlay/PlayerProviderTest.kt    | 41 ++++++++++
 .../kotlin/space/subread/overlay/PlayerProvider.kt | 90 +++++++++++++++++++++-
 3 files changed, 154 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 3f13143..79f6517 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,31 @@ controls the player. The panel does not need to be on the screen.
 The [SubRead plugin for KOReader](https://github.com/equwal/subread.koplugin)
 uses this to turn the pages with the audiobook.
 
+## For flash card apps
+
+A query of `content://space.subread.overlay.player/line` returns one row: the
+subtitle line of now, with the report of the player as it came, so that a
+flash card app can put the line on a card and cut its sound. The columns:
+
+- `state`: the same line as `/state`.
+- `reported_position`, `reported_at`, `speed`, `playing`: the last report of
+  the player. `reported_at` is on the clock of `SystemClock.elapsedRealtime()`.
+  A pause reported a minute ago still maps each time of the media to the
+  moment it played.
+- `offset`: the shift the user set. A time of the subtitle file is `offset`
+  milliseconds later than the same moment of the player.
+- `index`, `start`, `end`, `text`, `before`, `after`: the line. `index` is -1
+  before the first line, and `text` is then null. The times are on the clock
+  of the subtitle file.
+
+The query parameter `position` (milliseconds, on the clock of the player)
+picks the line for that position in place of the position of now. The panel
+does not need to be on the screen, but the user must have chosen the `.srt`.
+
+[SubRead Anki](https://github.com/equwal/subread-anki) uses this. Choose it
+as the dictionary in step 4: a tap on a word then makes a card with the line,
+its sound and a picture of the screen.
+
 ## For caption apps
 
 An app that makes captions from live audio, for example speech recognition of
diff --git a/app/src/androidTest/kotlin/space/subread/overlay/PlayerProviderTest.kt b/app/src/androidTest/kotlin/space/subread/overlay/PlayerProviderTest.kt
index 3557c39..9c0a44f 100644
--- a/app/src/androidTest/kotlin/space/subread/overlay/PlayerProviderTest.kt
+++ b/app/src/androidTest/kotlin/space/subread/overlay/PlayerProviderTest.kt
@@ -2,6 +2,7 @@ package space.subread.overlay
 
 import android.media.session.MediaSession
 import android.media.session.PlaybackState
+import android.net.Uri
 import android.os.SystemClock
 import androidx.core.net.toUri
 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -11,6 +12,7 @@ import org.junit.Assert.assertTrue
 import org.junit.Assume.assumeTrue
 import org.junit.Test
 import org.junit.runner.RunWith
+import java.io.File
 
 /**
  * The provider that a reader app asks for the position. The test app plays the part of the
@@ -22,6 +24,7 @@ class PlayerProviderTest {
 
     private val context = InstrumentationRegistry.getInstrumentation().targetContext
     private val uri = "content://${PlayerProvider.AUTHORITY}/state".toUri()
+    private val lineUri = "content://${PlayerProvider.AUTHORITY}/${PlayerProvider.PATH_LINE}".toUri()
 
     private fun state(state: Int, position: Long, speed: Float = 1f) = PlaybackState.Builder()
         .setState(state, position, speed, SystemClock.elapsedRealtime())
@@ -91,4 +94,42 @@ class PlayerProviderTest {
             session.release()
         }
     }
+
+    /** A flash card app asks for the line of a position. No player and no notification access are needed for that. */
+    @Test
+    fun aFlashCardAppReadsTheLineOfAPosition() {
+        val store = Store(context)
+        val before = store.subtitles
+        val beforeOffset = store.offsetMs
+        val file = File(context.cacheDir, "line-test.srt").apply {
+            writeText("1\n00:01:30,000 --> 00:01:33,000\nFirst line.\n\n2\n00:01:36,000 --> 00:01:39,500\nSecond line.\n\n")
+        }
+        try {
+            store.subtitles = Uri.fromFile(file)
+            store.offsetMs = 1_000
+            // The player is at 95 s; the file is shifted 1 s later: the line at 96 s of the file.
+            val at95 = lineUri.buildUpon().appendQueryParameter(PlayerProvider.PARAM_POSITION, "95000").build()
+            context.contentResolver.query(at95, null, null, null, null)!!.use {
+                assertTrue(it.moveToFirst())
+                assertEquals("Second line.", it.getString(it.getColumnIndexOrThrow(PlayerProvider.COLUMN_TEXT)))
+                assertEquals(96_000L, it.getLong(it.getColumnIndexOrThrow(PlayerProvider.COLUMN_START)))
+                assertEquals(99_500L, it.getLong(it.getColumnIndexOrThrow(PlayerProvider.COLUMN_END)))
+                assertEquals(1L, it.getLong(it.getColumnIndexOrThrow(PlayerProvider.COLUMN_INDEX)))
+                assertEquals(1_000L, it.getLong(it.getColumnIndexOrThrow(PlayerProvider.COLUMN_OFFSET)))
+                assertEquals("First line.", it.getString(it.getColumnIndexOrThrow(PlayerProvider.COLUMN_BEFORE)))
+                assertTrue(it.isNull(it.getColumnIndexOrThrow(PlayerProvider.COLUMN_AFTER)))
+            }
+            // Before the first line: no text.
+            val at10 = lineUri.buildUpon().appendQueryParameter(PlayerProvider.PARAM_POSITION, "10000").build()
+            context.contentResolver.query(at10, null, null, null, null)!!.use {
+                assertTrue(it.moveToFirst())
+                assertEquals(-1L, it.getLong(it.getColumnIndexOrThrow(PlayerProvider.COLUMN_INDEX)))
+                assertTrue(it.isNull(it.getColumnIndexOrThrow(PlayerProvider.COLUMN_TEXT)))
+            }
+        } finally {
+            store.subtitles = before
+            store.offsetMs = beforeOffset
+            file.delete()
+        }
+    }
 }
diff --git a/app/src/main/kotlin/space/subread/overlay/PlayerProvider.kt b/app/src/main/kotlin/space/subread/overlay/PlayerProvider.kt
index bc173d4..809d2dd 100644
--- a/app/src/main/kotlin/space/subread/overlay/PlayerProvider.kt
+++ b/app/src/main/kotlin/space/subread/overlay/PlayerProvider.kt
@@ -11,7 +11,10 @@ import android.media.session.PlaybackState
 import android.net.Uri
 import android.os.Bundle
 import android.os.SystemClock
+import android.provider.DocumentsContract
+import space.subread.overlay.core.CueIndex
 import space.subread.overlay.core.PlayClock
+import space.subread.overlay.core.Srt
 
 /**
  * Gives the position of the media player to other apps, and takes play, pause and seek from them.
@@ -26,6 +29,14 @@ import space.subread.overlay.core.PlayClock
  * The position is in milliseconds, computed for the moment of the query. A problem is one line
  * `error=<reason>`: `no_notification_access` or `no_player`.
  *
+ * `query` of the path `line` returns the subtitle line of now, for a flash card app: the row
+ * has `state` and the raw report of the player (`reported_position`, `reported_at` on the
+ * clock of `SystemClock.elapsedRealtime()`, `speed`, `playing`), the `offset` the user set, and
+ * the line: `index` (-1 before the first line), `start`, `end`, `text`, `before`, `after`. The
+ * times of the line are on the clock of the subtitle file: `offset` later than the player. The
+ * query parameter `position` (milliseconds, on the clock of the player) picks the line for
+ * that position in place of the position of now.
+ *
  * `call` takes the method `play`, `pause` or `seek` (the argument is the position in
  * milliseconds), and returns the state line in the bundle key `state`.
  *
@@ -43,6 +54,12 @@ class PlayerProvider : ContentProvider() {
     private val sessions by lazy { context!!.getSystemService(MediaSessionManager::class.java) }
     private val listener by lazy { ComponentName(context!!, MediaListener::class.java) }
 
+    /** The subtitle file, read once for the `line` rows and again when the user chooses another. */
+    private var cachedFile: String? = null
+    private var cachedModified: Long? = null
+    private var cachedIndex: CueIndex? = null
+    private val cacheLock = Any()
+
     override fun onCreate(): Boolean = true
 
     override fun query(
@@ -51,7 +68,10 @@ class PlayerProvider : ContentProvider() {
         selection: String?,
         selectionArgs: Array<String>?,
         sortOrder: String?,
-    ): Cursor = MatrixCursor(arrayOf(COLUMN_STATE)).apply { addRow(arrayOf(stateLine())) }
+    ): Cursor {
+        if (uri.lastPathSegment == PATH_LINE) return lineRow(uri.getQueryParameter(PARAM_POSITION)?.toLongOrNull())
+        return MatrixCursor(arrayOf(COLUMN_STATE)).apply { addRow(arrayOf(stateLine())) }
+    }
 
     override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
         if (method == METHOD_LINE || method == METHOD_END) {
@@ -89,7 +109,56 @@ class PlayerProvider : ContentProvider() {
         return stateLine(state, player.packageName, SystemClock.elapsedRealtime())
     }
 
-    override fun getType(uri: Uri): String = "vnd.android.cursor.item/vnd.space.subread.overlay.state"
+    /** The `line` row: the report of the player as it came, and the line for its position of now, or for [positionOverride]. */
+    private fun lineRow(positionOverride: Long?): Cursor {
+        val cursor = MatrixCursor(LINE_COLUMNS)
+        val state = runCatching { player()?.playbackState }.getOrNull()
+        val nowMs = SystemClock.elapsedRealtime()
+        val reportedPosition = state?.position?.takeIf { it >= 0 } ?: -1L
+        val reportedAt = if (state == null) -1L else state.lastPositionUpdateTime.takeIf { it > 0 } ?: nowMs
+        val speed = state?.playbackSpeed?.takeIf { it > 0f } ?: 1f
+        val playing = state?.state == PlaybackState.STATE_PLAYING
+        val store = Store(context!!)
+        val offset = store.offsetMs
+        val position = positionOverride
+            ?: if (reportedPosition >= 0) PlayClock(reportedPosition, reportedAt, speed, playing).positionAt(nowMs) else null
+        val index = subtitles(store)
+        val at = if (position != null && index != null) index.indexAt(position + offset) else -1
+        val cues = index?.cues.orEmpty()
+        val cue = cues.getOrNull(at)
+        cursor.addRow(
+            arrayOf<Any?>(
+                stateLine(), reportedPosition, reportedAt, speed, if (playing) 1 else 0, offset,
+                at, cue?.startMs, cue?.endMs, cue?.text, cues.getOrNull(at - 1)?.text, cues.getOrNull(at + 1)?.text,
+            ),
+        )
+        return cursor
+    }
+
+    /** The cues of the chosen subtitle file, or null without one. Read again when the file or its date changed. */
+    private fun subtitles(store: Store): CueIndex? {
+        val file = store.subtitles ?: return null
+        val resolver = context!!.contentResolver
+        val modified = runCatching {
+            resolver.query(file, arrayOf(DocumentsContract.Document.COLUMN_LAST_MODIFIED), null, null, null)?.use {
+                if (it.moveToFirst() && !it.isNull(0)) it.getLong(0) else null
+            }
+        }.getOrNull()
+        synchronized(cacheLock) {
+            if (file.toString() == cachedFile && modified == cachedModified) return cachedIndex
+            val cues = runCatching {
+                resolver.openInputStream(file)!!.use { Srt.parse(it.readBytes().decodeToString()) }
+            }.getOrDefault(emptyList())
+            cachedFile = file.toString()
+            cachedModified = modified
+            cachedIndex = CueIndex(cues)
+            return cachedIndex
+        }
+    }
+
+    override fun getType(uri: Uri): String =
+        if (uri.lastPathSegment == PATH_LINE) "vnd.android.cursor.item/vnd.space.subread.overlay.line"
+        else "vnd.android.cursor.item/vnd.space.subread.overlay.state"
 
     override fun insert(uri: Uri, values: ContentValues?): Uri? = null
 
@@ -100,7 +169,24 @@ class PlayerProvider : ContentProvider() {
     companion object {
         // The debug build has its own authority, so it installs next to the release.
         const val AUTHORITY = BuildConfig.APPLICATION_ID + ".player"
+        const val PATH_LINE = "line"
+        const val PARAM_POSITION = "position"
         const val COLUMN_STATE = "state"
+        const val COLUMN_REPORTED_POSITION = "reported_position"
+        const val COLUMN_REPORTED_AT = "reported_at"
+        const val COLUMN_SPEED = "speed"
+        const val COLUMN_PLAYING = "playing"
+        const val COLUMN_OFFSET = "offset"
+        const val COLUMN_INDEX = "index"
+        const val COLUMN_START = "start"
+        const val COLUMN_END = "end"
+        const val COLUMN_TEXT = "text"
+        const val COLUMN_BEFORE = "before"
+        const val COLUMN_AFTER = "after"
+        val LINE_COLUMNS = arrayOf(
+            COLUMN_STATE, COLUMN_REPORTED_POSITION, COLUMN_REPORTED_AT, COLUMN_SPEED, COLUMN_PLAYING, COLUMN_OFFSET,
+            COLUMN_INDEX, COLUMN_START, COLUMN_END, COLUMN_TEXT, COLUMN_BEFORE, COLUMN_AFTER,
+        )
         const val METHOD_PLAY = "play"
         const val METHOD_PAUSE = "pause"
         const val METHOD_SEEK = "seek"