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 23e81d7038432d1764167f6635ae183ac05865f0
equwal <truex@equwal.com>
2026-09-21 16:32:25 -0700

Give the position of the player to reader apps

A reader app has no notification access, so it cannot see the media
session of the player. PlayerProvider is a content provider that
answers for it: a query returns the state line with the position of
now, and call with play, pause or seek controls the player. KOReader
with the SubRead plugin uses it to turn the pages with the audiobook.

 README.md                                          |  19 ++++
 .../space/subread/overlay/PlayerProviderTest.kt    |  94 ++++++++++++++++++
 app/src/main/AndroidManifest.xml                   |   9 ++
 .../kotlin/space/subread/overlay/PlayerProvider.kt | 108 +++++++++++++++++++++
 4 files changed, 230 insertions(+)
diff --git a/README.md b/README.md
index 52353e6..4ca3e22 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,25 @@ 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
 book has no such problem.
 
+## For reader apps
+
+A reader app has no notification access, so it cannot see the position of
+the player. This app answers for it, with a content provider at
+`content://space.subread.overlay.player/state`. A query returns one row with
+the column `state`:
+
+```
+playing=1;position=96153;speed=1.0;package=de.ph1b.audiobook
+```
+
+The position is in milliseconds, for the moment of the query. A problem is
+`error=no_notification_access` or `error=no_player`. `call` with the method
+`play`, `pause` or `seek` (the argument is the position in milliseconds)
+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.
+
 ## Build
 
 ```
diff --git a/app/src/androidTest/kotlin/space/subread/overlay/PlayerProviderTest.kt b/app/src/androidTest/kotlin/space/subread/overlay/PlayerProviderTest.kt
new file mode 100644
index 0000000..3557c39
--- /dev/null
+++ b/app/src/androidTest/kotlin/space/subread/overlay/PlayerProviderTest.kt
@@ -0,0 +1,94 @@
+package space.subread.overlay
+
+import android.media.session.MediaSession
+import android.media.session.PlaybackState
+import android.os.SystemClock
+import androidx.core.net.toUri
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * The provider that a reader app asks for the position. The test app plays the part of the
+ * player with its own media session. The device must give this app notification access, else
+ * the test is skipped.
+ */
+@RunWith(AndroidJUnit4::class)
+class PlayerProviderTest {
+
+    private val context = InstrumentationRegistry.getInstrumentation().targetContext
+    private val uri = "content://${PlayerProvider.AUTHORITY}/state".toUri()
+
+    private fun state(state: Int, position: Long, speed: Float = 1f) = PlaybackState.Builder()
+        .setState(state, position, speed, SystemClock.elapsedRealtime())
+        .setActions(PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_SEEK_TO)
+        .build()
+
+    private fun read(): Map<String, String> {
+        val cursor = context.contentResolver.query(uri, null, null, null, null)!!
+        cursor.use {
+            assertTrue(it.moveToFirst())
+            val line = it.getString(it.getColumnIndexOrThrow(PlayerProvider.COLUMN_STATE))
+            return line.split(';').associate { part -> part.substringBefore('=') to part.substringAfter('=') }
+        }
+    }
+
+    @Test
+    fun theStateLineHasThePositionOfNow() {
+        val paused = PlaybackState.Builder().setState(PlaybackState.STATE_PAUSED, 1_000, 1f, 5_000).build()
+        assertEquals(
+            "playing=0;position=1000;speed=1.0;package=p",
+            PlayerProvider.stateLine(paused, "p", 9_000),
+        )
+        val playing = PlaybackState.Builder().setState(PlaybackState.STATE_PLAYING, 1_000, 2f, 5_000).build()
+        assertEquals(
+            "playing=1;position=9000;speed=2.0;package=p",
+            PlayerProvider.stateLine(playing, "p", 9_000),
+        )
+        val unknown = PlaybackState.Builder()
+            .setState(PlaybackState.STATE_PLAYING, PlaybackState.PLAYBACK_POSITION_UNKNOWN, 1f, 0).build()
+        assertEquals("playing=1;position=-1;speed=1.0;package=p", PlayerProvider.stateLine(unknown, "p", 9_000))
+    }
+
+    @Test
+    fun aReaderAppReadsThePlayerAndControlsIt() {
+        assumeTrue("notification access is not given to this app", MediaListener.isAllowed(context))
+        val session = MediaSession(context, "test player")
+        val seeks = mutableListOf<Long>()
+        var plays = 0
+        var pauses = 0
+        session.setCallback(object : MediaSession.Callback() {
+            override fun onPlay() { plays++ }
+            override fun onPause() { pauses++ }
+            override fun onSeekTo(pos: Long) { seeks += pos }
+        })
+        try {
+            session.setPlaybackState(state(PlaybackState.STATE_PLAYING, 96_000, speed = 1.5f))
+            session.isActive = true
+
+            val seen = read()
+            assertEquals("1", seen["playing"])
+            assertEquals("1.5", seen["speed"])
+            assertEquals(context.packageName, seen["package"])
+            val position = seen["position"]!!.toLong()
+            assertTrue("position $position is not near 96000", position in 96_000..97_500)
+
+            val resolver = context.contentResolver
+            resolver.call(uri, PlayerProvider.METHOD_PAUSE, null, null)
+            resolver.call(uri, PlayerProvider.METHOD_PLAY, null, null)
+            val answer = resolver.call(uri, PlayerProvider.METHOD_SEEK, "12345", null)!!
+            val until = SystemClock.elapsedRealtime() + 5_000
+            while ((plays < 1 || pauses < 1 || seeks.isEmpty()) && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
+            assertEquals(1, plays)
+            assertEquals(1, pauses)
+            assertEquals(listOf(12_345L), seeks)
+            assertTrue(answer.getString(PlayerProvider.COLUMN_STATE)!!.startsWith("playing="))
+        } finally {
+            session.release()
+        }
+    }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 79df7ca..3634582 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -33,6 +33,15 @@
             This service reads no notification. The system keeps it running, so the overlay
             needs no foreground service and no notification of its own.
         -->
+        <!--
+            Gives the position of the player to a reader app, and takes play, pause and seek from
+            it. KOReader with the SubRead plugin uses this. See PlayerProvider.
+        -->
+        <provider
+            android:name=".PlayerProvider"
+            android:authorities="space.subread.overlay.player"
+            android:exported="true" />
+
         <service
             android:name=".MediaListener"
             android:exported="true"
diff --git a/app/src/main/kotlin/space/subread/overlay/PlayerProvider.kt b/app/src/main/kotlin/space/subread/overlay/PlayerProvider.kt
new file mode 100644
index 0000000..26e8fdc
--- /dev/null
+++ b/app/src/main/kotlin/space/subread/overlay/PlayerProvider.kt
@@ -0,0 +1,108 @@
+package space.subread.overlay
+
+import android.content.ComponentName
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.media.session.MediaController
+import android.media.session.MediaSessionManager
+import android.media.session.PlaybackState
+import android.net.Uri
+import android.os.Bundle
+import android.os.SystemClock
+import space.subread.overlay.core.PlayClock
+
+/**
+ * Gives the position of the media player to other apps, and takes play, pause and seek from them.
+ *
+ * A reader app, for example KOReader with the SubRead plugin, has no notification access, so it
+ * cannot see the media session of the player. This app has that access, so it answers for them.
+ *
+ * `query` returns one row with one column, `state`. Its value is one line:
+ *
+ *     playing=1;position=96153;speed=1.0;package=de.ph1b.audiobook
+ *
+ * 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`.
+ *
+ * `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`.
+ *
+ * The provider is open to each app. Each app can already send the media keys to the player, so
+ * this gives no new control over the device.
+ */
+class PlayerProvider : ContentProvider() {
+
+    private val sessions by lazy { context!!.getSystemService(MediaSessionManager::class.java) }
+    private val listener by lazy { ComponentName(context!!, MediaListener::class.java) }
+
+    override fun onCreate(): Boolean = true
+
+    override fun query(
+        uri: Uri,
+        projection: Array<String>?,
+        selection: String?,
+        selectionArgs: Array<String>?,
+        sortOrder: String?,
+    ): Cursor = MatrixCursor(arrayOf(COLUMN_STATE)).apply { addRow(arrayOf(stateLine())) }
+
+    override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
+        val player = runCatching { player() }.getOrNull()
+        val controls = player?.transportControls
+        when (method) {
+            METHOD_PLAY -> controls?.play()
+            METHOD_PAUSE -> controls?.pause()
+            METHOD_SEEK -> arg?.toLongOrNull()?.let { controls?.seekTo(it) }
+        }
+        return Bundle().apply { putString(COLUMN_STATE, stateLine()) }
+    }
+
+    /** The session that plays, else the first active one. Null when there is none. */
+    private fun player(): MediaController? {
+        val active = sessions.getActiveSessions(listener)
+        return active.firstOrNull { it.playbackState?.state == PlaybackState.STATE_PLAYING } ?: active.firstOrNull()
+    }
+
+    private fun stateLine(): String {
+        val player = try {
+            player()
+        } catch (e: SecurityException) {
+            return "error=$ERROR_NO_ACCESS"
+        } ?: return "error=$ERROR_NO_PLAYER"
+        val state = player.playbackState ?: return "error=$ERROR_NO_PLAYER"
+        return stateLine(state, player.packageName, SystemClock.elapsedRealtime())
+    }
+
+    override fun getType(uri: Uri): String = "vnd.android.cursor.item/vnd.space.subread.overlay.state"
+
+    override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+    override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int = 0
+
+    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
+
+    companion object {
+        const val AUTHORITY = "space.subread.overlay.player"
+        const val COLUMN_STATE = "state"
+        const val METHOD_PLAY = "play"
+        const val METHOD_PAUSE = "pause"
+        const val METHOD_SEEK = "seek"
+        const val ERROR_NO_ACCESS = "no_notification_access"
+        const val ERROR_NO_PLAYER = "no_player"
+
+        /** The state line for a report of the player, at [nowMs] on the clock of the device. */
+        fun stateLine(state: PlaybackState, packageName: String, nowMs: Long): String {
+            val playing = state.state == PlaybackState.STATE_PLAYING
+            val speed = state.playbackSpeed.takeIf { it > 0f } ?: 1f
+            // A report without a position: the same as the panel, the position is unknown.
+            val position = if (state.position >= 0) {
+                val reportedAt = state.lastPositionUpdateTime.takeIf { it > 0 } ?: nowMs
+                PlayClock(state.position, reportedAt, speed, playing).positionAt(nowMs)
+            } else {
+                -1
+            }
+            return "playing=${if (playing) 1 else 0};position=$position;speed=$speed;package=$packageName"
+        }
+    }
+}