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.