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 232b3124bb54c729b506ef16de06f1c56f828aec
equwal <truex@equwal.com>
2026-09-21 17:00:52 -0700

Add the play button, the see-through panel and the lines around

"Look up" pauses the player, so that the narration does not run on
while the user reads the dictionary. A play button on the panel starts
the player again, or pauses it.

A slider on the screen of the app sets how much of the player shows
through the panel. The text and the buttons stay as they are.

A setting shows the line before and the line after the line of now on
the panel, small and grey. The panel grows to fit them. A tap on a word
of those lines selects it too.

The follower reports the index of the line and the play state, not
the cue, so that the listener can find the lines around.

 README.md                                          |   7 +-
 .../kotlin/space/subread/overlay/OverlayTest.kt    | 110 +++++++++++++++++++--
 .../main/kotlin/space/subread/overlay/Follower.kt  |  27 ++++-
 .../kotlin/space/subread/overlay/MainActivity.kt   |  46 +++++++++
 .../kotlin/space/subread/overlay/MediaListener.kt  |  17 +++-
 .../kotlin/space/subread/overlay/OverlayView.kt    |  44 ++++++++-
 app/src/main/kotlin/space/subread/overlay/Store.kt |  10 ++
 app/src/main/res/values/strings.xml                |   7 ++
 .../metadata/android/en-US/full_description.txt    |   3 +-
 9 files changed, 246 insertions(+), 25 deletions(-)
diff --git a/README.md b/README.md
index 4ca3e22..c7c2dab 100644
--- a/README.md
+++ b/README.md
@@ -25,11 +25,16 @@ the audiobook and its ebook, in the browser or on
    Aedict, AnkiDroid, a translator).
 5. Press "Show the subtitles", then start the player.
 
+Two settings change the panel: how much of the player shows through it, and
+whether it shows the line before and the line after the line of now.
+
 On the panel:
 
 - `≡` moves the panel.
 - A tap selects the word under the finger. A drag selects more words.
-  "Look up" sends the selection to the dictionary. "Copy" copies it.
+  "Look up" pauses the player and sends the selection to the dictionary.
+  "Copy" copies it.
+- `▶` starts the player again after a lookup. `⏸` pauses it.
 - `⋯` opens the timing row. `−0.5 s` and `+0.5 s` shift the subtitles.
   `◀ line` and `line ▶` make the line before, or the next line, the line of
   now. Use them when the media has an intro that the subtitle file does not
diff --git a/app/src/androidTest/kotlin/space/subread/overlay/OverlayTest.kt b/app/src/androidTest/kotlin/space/subread/overlay/OverlayTest.kt
index f332b29..cdbbaae 100644
--- a/app/src/androidTest/kotlin/space/subread/overlay/OverlayTest.kt
+++ b/app/src/androidTest/kotlin/space/subread/overlay/OverlayTest.kt
@@ -71,10 +71,21 @@ class OverlayViewTest {
         override fun onDragEnd() = Unit
         override fun onClose() = Unit
         override fun onLookUp(word: String) { lookedUp += word }
+        override fun onTogglePlay() = Unit
         override fun onShiftLines(steps: Int) = Unit
         override fun onNudge(ms: Long) = Unit
     }
 
+    /** The x of the middle of the character at [offset], in the panel. */
+    private fun OverlayView.middleOf(offset: Int): Float {
+        val layout = text.layout
+        return text.totalPaddingLeft + (layout.getPrimaryHorizontal(offset) + layout.getPrimaryHorizontal(offset + 1)) / 2
+    }
+
+    /** A y just over the baseline of the row of the character at [offset], in the panel. */
+    private fun OverlayView.rowOf(offset: Int): Float =
+        text.totalPaddingTop + text.layout.getLineBaseline(text.layout.getLineForOffset(offset)) - 5f
+
     @Test
     fun aTapSelectsTheWordUnderTheFingerAndLookUpSendsIt() {
         ActivityScenario.launch(MainActivity::class.java).use { scenario ->
@@ -87,18 +98,15 @@ class OverlayViewTest {
             }
             InstrumentationRegistry.getInstrumentation().waitForIdleSync()
             scenario.onActivity {
-                val layout = panel.text.layout
                 val start = panel.line.indexOf("along")
                 // The middle of the third letter of "along".
-                val x = panel.text.totalPaddingLeft + (layout.getPrimaryHorizontal(start + 2) + layout.getPrimaryHorizontal(start + 3)) / 2
-                val y = panel.text.totalPaddingTop + layout.getLineBaseline(0) - 5f
-                panel.selectAt(x, y)
+                panel.selectAt(panel.middleOf(start + 2), panel.rowOf(start + 2))
                 assertEquals(start..start + 4, panel.selection)
 
                 // A drag to the last word selects the three words.
-                val end = panel.text.totalPaddingLeft + layout.getPrimaryHorizontal(panel.line.length - 1) - 2f
-                panel.selectAt(end, y, extend = true)
-                assertEquals(start..panel.line.lastIndex, panel.selection)
+                val last = panel.line.lastIndex
+                panel.selectAt(panel.middleOf(last), panel.rowOf(last), extend = true)
+                assertEquals(start..last, panel.selection)
 
                 val found = arrayListOf<android.view.View>()
                 panel.findViewsWithText(found, "Look up", android.view.View.FIND_VIEWS_WITH_TEXT)
@@ -111,6 +119,50 @@ class OverlayViewTest {
             }
         }
     }
+
+    @Test
+    fun theLinesAroundAreOnTheirOwnRowsAndAWordOfThemCanBeSelected() {
+        ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+            lateinit var panel: OverlayView
+            scenario.onActivity {
+                panel = OverlayView(it, events)
+                panel.setTextSize(30f)
+                it.addContentView(panel, ViewGroup.LayoutParams(-1, -2))
+                panel.showLine("the line of now", "the line before", "the line after")
+            }
+            InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+            scenario.onActivity {
+                assertEquals("the line before\nthe line of now\nthe line after", panel.line)
+                val layout = panel.text.layout
+                val now = panel.line.indexOf("the line of now")
+                val start = panel.line.indexOf("after")
+                assertTrue("each line on its own row", layout.getLineForOffset(0) < layout.getLineForOffset(now))
+                assertTrue("each line on its own row", layout.getLineForOffset(now) < layout.getLineForOffset(start))
+                panel.selectAt(panel.middleOf(start + 1), panel.rowOf(start + 1))
+                assertEquals(start..start + 4, panel.selection)
+
+                // The same lines again change nothing; one line alone is one row again.
+                panel.showLine("the line of now", "the line before", "the line after")
+                assertEquals(start..start + 4, panel.selection)
+                panel.showLine("the line of now")
+                assertEquals("the line of now", panel.line)
+                assertNull(panel.selection)
+            }
+        }
+    }
+
+    @Test
+    fun theTransparencyIsTheAlphaOfTheBackground() {
+        ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+            scenario.onActivity {
+                val panel = OverlayView(it, events)
+                panel.setTransparency(40)
+                assertEquals(153, panel.background.alpha)
+                panel.setTransparency(0)
+                assertEquals(255, panel.background.alpha)
+            }
+        }
+    }
 }
 
 /** A media session of the test plays the part of Voice, VLC or YouTube. */
@@ -141,7 +193,7 @@ class FollowerTest {
             session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 1_500))
             session.isActive = true
             onMain {
-                follower = Follower(main) { cue, _ -> lines += cue?.text }
+                follower = Follower(main) { at, _, _ -> lines += follower.index.cues.getOrNull(at)?.text }
                 follower.index = index
                 follower.follow(listOf(MediaController(context, session.sessionToken)))
             }
@@ -184,7 +236,7 @@ class FollowerTest {
             session.setPlaybackState(state(PlaybackState.STATE_PLAYING, PlaybackState.PLAYBACK_POSITION_UNKNOWN))
             session.isActive = true
             onMain {
-                follower = Follower(main) { cue, _ -> lines += cue?.text }
+                follower = Follower(main) { at, _, _ -> lines += follower.index.cues.getOrNull(at)?.text }
                 follower.index = CueIndex(listOf(Cue(0, 300, "first"), Cue(400, 900, "second")))
                 follower.follow(listOf(MediaController(context, session.sessionToken)))
             }
@@ -204,9 +256,47 @@ class FollowerTest {
     fun noPlayerIsSaidSo() {
         val players = CopyOnWriteArrayList<Boolean>()
         onMain {
-            val follower = Follower(main) { _, hasPlayer -> players += hasPlayer }
+            val follower = Follower(main) { _, hasPlayer, _ -> players += hasPlayer }
             follower.follow(emptyList())
         }
         assertEquals(false, players.last())
     }
+
+    @Test
+    fun aLookupPausesThePlayerAndTheButtonStartsItAgain() {
+        val session = MediaSession(context, "test player")
+        val playing = CopyOnWriteArrayList<Boolean>()
+        var plays = 0
+        var pauses = 0
+        session.setCallback(object : MediaSession.Callback() {
+            override fun onPlay() { plays++ }
+            override fun onPause() { pauses++ }
+        }, main)
+        lateinit var follower: Follower
+        try {
+            session.setPlaybackState(state(PlaybackState.STATE_PLAYING, 1_500))
+            session.isActive = true
+            onMain {
+                follower = Follower(main) { _, _, isPlaying -> playing += isPlaying }
+                follower.index = index
+                follower.follow(listOf(MediaController(context, session.sessionToken)))
+            }
+            val until = SystemClock.elapsedRealtime() + 5_000
+            while (playing.lastOrNull() != true && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
+            assertEquals(true, playing.last())
+
+            onMain { assertTrue("a player that plays is paused", follower.pause()) }
+            session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 1_500))
+            while (playing.lastOrNull() != false && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
+            assertEquals(false, playing.last())
+            onMain { assertEquals("a paused player is not paused again", false, follower.pause()) }
+            onMain { follower.play() }
+            while ((plays < 1 || pauses < 1) && SystemClock.elapsedRealtime() < until) Thread.sleep(20)
+            assertEquals(1, pauses)
+            assertEquals(1, plays)
+        } finally {
+            onMain { follower.stop() }
+            session.release()
+        }
+    }
 }
diff --git a/app/src/main/kotlin/space/subread/overlay/Follower.kt b/app/src/main/kotlin/space/subread/overlay/Follower.kt
index 0f07385..630f1fd 100644
--- a/app/src/main/kotlin/space/subread/overlay/Follower.kt
+++ b/app/src/main/kotlin/space/subread/overlay/Follower.kt
@@ -4,7 +4,6 @@ import android.media.session.MediaController
 import android.media.session.PlaybackState
 import android.os.Handler
 import android.os.SystemClock
-import space.subread.overlay.core.Cue
 import space.subread.overlay.core.CueIndex
 import space.subread.overlay.core.PlayClock
 
@@ -22,8 +21,11 @@ import space.subread.overlay.core.PlayClock
 class Follower(
     private val handler: Handler,
     private val now: () -> Long = SystemClock::elapsedRealtime,
-    /** The line that is on, and true when there is a player to follow. */
-    private val onLine: (Cue?, Boolean) -> Unit,
+    /**
+     * The index of the line that is on in [index] (-1 before the first line), true when there is
+     * a player to follow, and true when that player plays.
+     */
+    private val onLine: (Int, Boolean, Boolean) -> Unit,
 ) {
     var index: CueIndex = CueIndex(emptyList())
         set(value) {
@@ -64,6 +66,21 @@ class Follower(
         update()
     }
 
+    /** True while the player that is followed plays. */
+    val playing: Boolean
+        get() = controller?.playbackState?.state == PlaybackState.STATE_PLAYING
+
+    /** Pauses the player. True when it played. */
+    fun pause(): Boolean {
+        if (!playing) return false
+        controller?.transportControls?.pause()
+        return true
+    }
+
+    fun play() {
+        controller?.transportControls?.play()
+    }
+
     fun stop() {
         handler.removeCallbacks(wake)
         controller?.unregisterCallback(callback)
@@ -98,12 +115,12 @@ class Follower(
         handler.removeCallbacks(wake)
         val clock = clock()
         if (clock == null) {
-            onLine(null, false)
+            onLine(-1, false, false)
             return
         }
         val position = clock.positionAt(now()) + offsetMs
         val at = index.indexAt(position)
-        onLine(index.cues.getOrNull(at), true)
+        onLine(at, true, clock.playing)
         val next = index.nextChangeAfter(position) ?: return
         clock.waitUntil(next - offsetMs, now())?.let { handler.postDelayed(wake, it) }
     }
diff --git a/app/src/main/kotlin/space/subread/overlay/MainActivity.kt b/app/src/main/kotlin/space/subread/overlay/MainActivity.kt
index 148e2f1..8b8b2ae 100644
--- a/app/src/main/kotlin/space/subread/overlay/MainActivity.kt
+++ b/app/src/main/kotlin/space/subread/overlay/MainActivity.kt
@@ -11,10 +11,12 @@ import android.net.Uri
 import android.os.Bundle
 import android.provider.OpenableColumns
 import android.provider.Settings
+import android.view.Gravity
 import android.view.View
 import android.widget.Button
 import android.widget.LinearLayout
 import android.widget.ScrollView
+import android.widget.SeekBar
 import android.widget.TextView
 import android.widget.Toast
 import androidx.core.net.toUri
@@ -75,6 +77,18 @@ class MainActivity : Activity() {
         step(R.string.step_dictionary, dictionaryName(), getString(R.string.choose)) { chooseDictionary() }
         step(R.string.step_size, "${store.textSizeSp.toInt()} sp", null) {}
         row(button("−") { size(-2f) }, button("+") { size(2f) })
+        step(R.string.step_transparency, getString(R.string.step_transparency_why), null) {}
+        content.addView(transparencySlider(), wide())
+        val around = store.linesAround
+        step(
+            R.string.step_lines,
+            getString(if (around) R.string.lines_three else R.string.lines_one),
+            getString(if (around) R.string.lines_show_one else R.string.lines_show_three),
+        ) {
+            store.linesAround = !around
+            MediaListener.instance?.reload()
+            draw()
+        }
 
         val shown = store.shown && MediaListener.instance != null
         content.addView(button(getString(if (shown) R.string.hide else R.string.show)) { togglePanel() }.apply {
@@ -108,6 +122,38 @@ class MainActivity : Activity() {
         draw()
     }
 
+    /** A slider from 0 % (a white panel) to 90 %. The panel changes while the finger moves. */
+    @SuppressLint("SetTextI18n")
+    private fun transparencySlider(): View {
+        val label = TextView(this).apply {
+            setTextColor(Color.BLACK)
+            textSize = 15f
+        }
+        val slider = SeekBar(this).apply {
+            max = 90
+            progress = store.transparencyPercent
+            setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
+                override fun onProgressChanged(bar: SeekBar, value: Int, fromUser: Boolean) {
+                    label.text = "$value %"
+                    if (fromUser) {
+                        store.transparencyPercent = value
+                        MediaListener.instance?.reload()
+                    }
+                }
+
+                override fun onStartTrackingTouch(bar: SeekBar) = Unit
+
+                override fun onStopTrackingTouch(bar: SeekBar) = Unit
+            })
+        }
+        label.text = "${slider.progress} %"
+        return LinearLayout(this).apply {
+            gravity = Gravity.CENTER_VERTICAL
+            addView(slider, LinearLayout.LayoutParams(0, -2, 1f))
+            addView(label, LinearLayout.LayoutParams(-2, -2).apply { leftMargin = dp(12) })
+        }
+    }
+
     private fun dictionaryName(): String {
         val chosen = store.dictionary ?: return getString(R.string.dictionary_ask)
         return dictionaries().firstOrNull { it.second == chosen }?.first ?: getString(R.string.dictionary_ask)
diff --git a/app/src/main/kotlin/space/subread/overlay/MediaListener.kt b/app/src/main/kotlin/space/subread/overlay/MediaListener.kt
index 3ed2a1a..c5a29aa 100644
--- a/app/src/main/kotlin/space/subread/overlay/MediaListener.kt
+++ b/app/src/main/kotlin/space/subread/overlay/MediaListener.kt
@@ -46,7 +46,7 @@ class MediaListener : NotificationListenerService(), OverlayView.Events {
 
     override fun onListenerConnected() {
         store = Store(this)
-        follower = Follower(handler) { cue, hasPlayer -> show(cue?.text, hasPlayer) }
+        follower = Follower(handler) { at, hasPlayer, playing -> show(at, hasPlayer, playing) }
         sessions.addOnActiveSessionsChangedListener(onSessions, me, handler)
         instance = this
         if (store.shown) showPanel()
@@ -82,6 +82,7 @@ class MediaListener : NotificationListenerService(), OverlayView.Events {
     fun reload() {
         val view = panel ?: return
         view.setTextSize(store.textSizeSp)
+        view.setTransparency(store.transparencyPercent)
         view.showOffset(store.offsetMs)
         follower.offsetMs = store.offsetMs
         val file = store.subtitles
@@ -105,13 +106,17 @@ class MediaListener : NotificationListenerService(), OverlayView.Events {
         panel = null
     }
 
-    private fun show(line: String?, hasPlayer: Boolean) {
+    private fun show(at: Int, hasPlayer: Boolean, playing: Boolean) {
         val view = panel ?: return
+        view.showPlaying(playing)
+        val cues = follower.index.cues
+        val line = cues.getOrNull(at)?.text
         when {
             store.subtitles == null -> view.showStatus(getString(R.string.status_no_file))
-            follower.index.size == 0 -> view.showStatus(getString(R.string.status_empty_file))
+            cues.isEmpty() -> view.showStatus(getString(R.string.status_empty_file))
             !hasPlayer -> view.showStatus(getString(R.string.status_no_player))
             line == null -> view.showStatus(getString(R.string.status_before_first_line))
+            store.linesAround -> view.showLine(line, cues.getOrNull(at - 1)?.text, cues.getOrNull(at + 1)?.text)
             else -> view.showLine(line)
         }
     }
@@ -129,12 +134,18 @@ class MediaListener : NotificationListenerService(), OverlayView.Events {
 
     override fun onClose() = hidePanel()
 
+    /** The player pauses for the lookup. The play button of the panel starts it again. */
     override fun onLookUp(word: String) {
+        follower.pause()
         runCatching { startActivity(Lookup.intent(word, store.dictionary)) }.onFailure {
             Toast.makeText(this, R.string.no_dictionary, Toast.LENGTH_LONG).show()
         }
     }
 
+    override fun onTogglePlay() {
+        if (!follower.pause()) follower.play()
+    }
+
     override fun onShiftLines(steps: Int) {
         follower.shiftLines(steps)
         keepOffset()
diff --git a/app/src/main/kotlin/space/subread/overlay/OverlayView.kt b/app/src/main/kotlin/space/subread/overlay/OverlayView.kt
index a072163..268d968 100644
--- a/app/src/main/kotlin/space/subread/overlay/OverlayView.kt
+++ b/app/src/main/kotlin/space/subread/overlay/OverlayView.kt
@@ -10,6 +10,7 @@ import android.text.SpannableString
 import android.text.Spanned
 import android.text.style.BackgroundColorSpan
 import android.text.style.ForegroundColorSpan
+import android.text.style.RelativeSizeSpan
 import android.util.TypedValue
 import android.view.Gravity
 import android.view.MotionEvent
@@ -36,14 +37,22 @@ class OverlayView(context: Context, private val events: Events) : LinearLayout(c
         fun onDragEnd()
         fun onClose()
         fun onLookUp(word: String)
+        /** The play button: pause the player when it plays, else start it. */
+        fun onTogglePlay()
         fun onShiftLines(steps: Int)
         fun onNudge(ms: Long)
     }
 
-    /** The line on the panel, without the selection marks. */
+    /**
+     * The text on the panel, without the selection marks. With the lines around, the line
+     * before and the line after are in it too, each on its own row.
+     */
     var line: String = ""
         private set
 
+    /** The parts of [line] that are the lines around: they are drawn small and grey. */
+    private var around: List<IntRange> = emptyList()
+
     /** The part of [line] that is selected. */
     var selection: IntRange? = null
         private set
@@ -52,6 +61,7 @@ class OverlayView(context: Context, private val events: Events) : LinearLayout(c
     private val lookUpRow = LinearLayout(context)
     private val syncRow = LinearLayout(context)
     private val offsetLabel = TextView(context)
+    private val playButton = button("▶", "Start the player") { events.onTogglePlay() }
     private var anchor: IntRange? = null
 
     init {
@@ -68,6 +78,7 @@ class OverlayView(context: Context, private val events: Events) : LinearLayout(c
         text.setLineSpacing(0f, 1.15f)
         selectWith(text)
         top.addView(text, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
+        top.addView(playButton, wrap())
         top.addView(button("⋯", "Timing") { toggle(syncRow) }, wrap())
         top.addView(button("✕", "Close the subtitles") { events.onClose() }, wrap())
         addView(top, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
@@ -92,14 +103,32 @@ class OverlayView(context: Context, private val events: Events) : LinearLayout(c
 
     fun setTextSize(sp: Float) = text.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
 
+    /** Lets [percent] of the player show through the panel. The text and the buttons stay as they are. */
+    fun setTransparency(percent: Int) {
+        background.mutate().alpha = 255 * (100 - percent.coerceIn(0, 100)) / 100
+    }
+
+    fun showPlaying(playing: Boolean) {
+        playButton.text = if (playing) "⏸" else "▶"
+        playButton.contentDescription = if (playing) "Pause the player" else "Start the player"
+    }
+
     fun showOffset(ms: Long) {
         offsetLabel.text = "%+.1f s".format(ms / 1000.0)
     }
 
-    /** Shows a subtitle line. The selection goes away when the line changes. */
-    fun showLine(value: String) {
-        if (value == line) return
-        line = value
+    /**
+     * Shows a subtitle line, with the line [before] it and the line [after] it when they are
+     * given. The selection goes away when the text changes.
+     */
+    fun showLine(value: String, before: String? = null, after: String? = null) {
+        val whole = listOfNotNull(before, value, after).joinToString("\n")
+        if (whole == line) return
+        line = whole
+        around = buildList {
+            if (before != null) add(0 until before.length)
+            if (after != null) add(whole.length - after.length until whole.length)
+        }
         text.setTypeface(Typeface.DEFAULT, Typeface.NORMAL)
         select(null)
     }
@@ -107,6 +136,7 @@ class OverlayView(context: Context, private val events: Events) : LinearLayout(c
     /** Shows a word of the app to the user, where the line would be: no player, no file. */
     fun showStatus(value: String) {
         line = ""
+        around = emptyList()
         selection = null
         lookUpRow.visibility = View.GONE
         text.setTypeface(Typeface.DEFAULT, Typeface.ITALIC)
@@ -129,6 +159,10 @@ class OverlayView(context: Context, private val events: Events) : LinearLayout(c
         selection = range
         if (range == null) anchor = null
         val marked = SpannableString(line)
+        for (part in around) {
+            marked.setSpan(ForegroundColorSpan(Color.DKGRAY), part.first, part.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+            marked.setSpan(RelativeSizeSpan(0.85f), part.first, part.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
+        }
         if (range != null) {
             marked.setSpan(BackgroundColorSpan(Color.BLACK), range.first, range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
             marked.setSpan(ForegroundColorSpan(Color.WHITE), range.first, range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
diff --git a/app/src/main/kotlin/space/subread/overlay/Store.kt b/app/src/main/kotlin/space/subread/overlay/Store.kt
index b243d2e..6180d97 100644
--- a/app/src/main/kotlin/space/subread/overlay/Store.kt
+++ b/app/src/main/kotlin/space/subread/overlay/Store.kt
@@ -32,6 +32,16 @@ class Store(context: Context) {
         get() = prefs.getFloat("text_size", 22f)
         set(value) = prefs.edit { putFloat("text_size", value.coerceIn(12f, 60f)) }
 
+    /** How much of the player shows through the panel, 0 to 90 percent. The text stays black. */
+    var transparencyPercent: Int
+        get() = prefs.getInt("transparency", 30)
+        set(value) = prefs.edit { putInt("transparency", value.coerceIn(0, 90)) }
+
+    /** True: the panel shows the line before and the line after the line of now too. */
+    var linesAround: Boolean
+        get() = prefs.getBoolean("lines_around", false)
+        set(value) = prefs.edit { putBoolean("lines_around", value) }
+
     /** Where the panel is, in pixels from the top left of the screen. */
     var x: Int
         get() = prefs.getInt("x", 0)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index cae45af..ecfbc9e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -20,6 +20,13 @@
     <string name="dictionary_ask">Ask each time</string>
     <string name="dictionary_none">No app on this device takes a selected text. Install a dictionary that has an entry in the text selection menu.</string>
     <string name="step_size">5. Text size</string>
+    <string name="step_transparency">6. See-through panel</string>
+    <string name="step_transparency_why">How much of the player shows through the panel. The text stays black.</string>
+    <string name="step_lines">7. Lines around the line</string>
+    <string name="lines_one">One line: the line that the narrator reads.</string>
+    <string name="lines_three">Three lines: the line before and the line after too, small and grey. The panel grows to fit them.</string>
+    <string name="lines_show_three">Show three lines</string>
+    <string name="lines_show_one">Show one line</string>
     <string name="allowed">Allowed</string>
     <string name="allow">Allow</string>
     <string name="choose">Choose</string>
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 11a6103..8b52840 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -3,7 +3,8 @@ SubRead Overlay shows the lines of a subtitle file (.srt) over the app that play
 <b>What it does</b>
 
 * Works with each player that publishes an Android media session: Voice, VLC, mpv-android, podcast apps and more. No player needs a change.
-* Tap a word to select it. Drag to select more words. "Look up" sends the selection to a dictionary, a translator or a flash card app. Each app with an entry in the text selection menu can be the target.
+* Tap a word to select it. Drag to select more words. "Look up" pauses the player and sends the selection to a dictionary, a translator or a flash card app. Each app with an entry in the text selection menu can be the target. A button on the panel starts the player again.
+* On request the panel shows the line before and the line after too, and lets the player show through.
 * The line stays on the panel until the next line starts, so there is time to look a word up.
 * A timing row shifts the subtitles by half a second, or makes the line before or the next line the line of now.
 * Black on white and nothing that moves: usable on an e-ink screen.