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


app/src/main/kotlin/space/subread/overlay/OverlayView.kt (11588 bytes)

1 package space.subread.overlay
2 
3 import android.annotation.SuppressLint
4 import android.content.ClipData
5 import android.content.ClipboardManager
6 import android.content.Context
7 import android.graphics.Color
8 import android.graphics.Typeface
9 import android.text.SpannableString
10 import android.text.Spanned
11 import android.text.style.BackgroundColorSpan
12 import android.text.style.ForegroundColorSpan
13 import android.text.style.RelativeSizeSpan
14 import android.util.TypedValue
15 import android.view.Gravity
16 import android.view.MotionEvent
17 import android.view.View
18 import android.widget.Button
19 import android.widget.LinearLayout
20 import android.widget.TextView
21 import androidx.core.view.isVisible
22 
23 /**
24  * The panel over the media player: the subtitle line, and a few buttons.
25  *
26  * A tap on the line selects the word under the finger and pauses the player at once. A drag
27  * makes the selection longer, word by word. When the finger lifts, the selection goes to the
28  * dictionary. When the dictionary closes, the selection goes away. The system text selection is not used: it needs a window with the input focus,
29  * and then the player below gets no keys.
30  *
31  * With SubRead Anki installed, the selection row has an "Anki" button: one tap makes a card
32  * from the word and the line.
33  *
34  * Black on white and nothing that moves, so that the panel is usable on an e-ink screen.
35  */
36 @SuppressLint("ViewConstructor", "SetTextI18n")
37 class OverlayView(context: Context, private val events: Events, anki: Boolean = false) : LinearLayout(context) {
38 
39     interface Events {
40         fun onDrag(dx: Float, dy: Float)
41         fun onDragEnd()
42         fun onClose()
43         /** A finger is on a word: the player pauses now, before the lookup. */
44         fun onTouchWord()
45         /** The finger lifted from a selection: the selection goes to the dictionary. */
46         fun onLookUp(word: String)
47         /** "Share": the selection goes to an app that the user picks in the share sheet. */
48         fun onShare(word: String)
49         /** "Anki": the selection and the line go to SubRead Anki as a card. */
50         fun onAnki(word: String) {}
51         /** The play button: pause the player when it plays, else start it. */
52         fun onTogglePlay()
53         fun onShiftLines(steps: Int)
54         fun onNudge(ms: Long)
55         /** The selection went away: a tap beside a word, or a new line. */
56         fun onSelectionCleared() {}
57     }
58 
59     /**
60      * The text on the panel, without the selection marks. With the lines around, the line
61      * before and the line after are in it too, each on its own row.
62      */
63     var line: String = ""
64         private set
65 
66     /** The parts of [line] that are the lines around: they are drawn small and grey. */
67     private var around: List<IntRange> = emptyList()
68 
69     /** The part of [line] that is selected. */
70     var selection: IntRange? = null
71         private set
72 
73     val text = TextView(context)
74     private val lookUpRow = LinearLayout(context)
75     private val syncRow = LinearLayout(context)
76     private val offsetLabel = TextView(context)
77     private val playButton = button("▶", "Start the player") { events.onTogglePlay() }
78     private var anchor: IntRange? = null
79 
80     init {
81         orientation = VERTICAL
82         setBackgroundResource(R.drawable.panel)
83         val pad = dp(6)
84         setPadding(pad, pad, pad, pad)
85 
86         val top = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL }
87         top.addView(button("≡", "Move the subtitles") {}.also(::dragWith), wrap())
88         text.setTextColor(Color.BLACK)
89         text.typeface = Typeface.DEFAULT
90         text.setPadding(dp(8), dp(4), dp(8), dp(4))
91         text.setLineSpacing(0f, 1.15f)
92         selectWith(text)
93         top.addView(text, LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f))
94         top.addView(playButton, wrap())
95         top.addView(button("⋯", "Timing") { toggle(syncRow) }, wrap())
96         top.addView(button("✕", "Close the subtitles") { events.onClose() }, wrap())
97         addView(top, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
98 
99         lookUpRow.gravity = Gravity.END
100         if (anki) lookUpRow.addView(button("Anki", "Make an Anki card") { anki() }, wrap())
101         lookUpRow.addView(button("Copy", null) { copy() }, wrap())
102         lookUpRow.addView(button("Share", null) { share() }, wrap())
103         lookUpRow.visibility = View.GONE
104         addView(lookUpRow, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
105 
106         syncRow.gravity = Gravity.CENTER
107         syncRow.addView(button("◀ line", "The line before is the line now") { events.onShiftLines(-1) }, wrap())
108         syncRow.addView(button("−0.5 s", "Subtitles later") { events.onNudge(-500) }, wrap())
109         offsetLabel.setTextColor(Color.BLACK)
110         offsetLabel.setPadding(dp(8), 0, dp(8), 0)
111         syncRow.addView(offsetLabel, wrap())
112         syncRow.addView(button("+0.5 s", "Subtitles sooner") { events.onNudge(500) }, wrap())
113         syncRow.addView(button("line ▶", "The next line is the line now") { events.onShiftLines(1) }, wrap())
114         syncRow.visibility = View.GONE
115         addView(syncRow, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT))
116     }
117 
118     fun setTextSize(sp: Float) = text.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp)
119 
120     /** Lets [percent] of the player show through the panel. The text and the buttons stay as they are. */
121     fun setTransparency(percent: Int) {
122         background.mutate().alpha = 255 * (100 - percent.coerceIn(0, 100)) / 100
123     }
124 
125     fun showPlaying(playing: Boolean) {
126         playButton.text = if (playing) "⏸" else "▶"
127         playButton.contentDescription = if (playing) "Pause the player" else "Start the player"
128     }
129 
130     fun showOffset(ms: Long) {
131         offsetLabel.text = "%+.1f s".format(ms / 1000.0)
132     }
133 
134     /**
135      * Shows a subtitle line, with the line [before] it and the line [after] it when they are
136      * given. The selection goes away when the text changes.
137      */
138     fun showLine(value: String, before: String? = null, after: String? = null) {
139         val whole = listOfNotNull(before, value, after).joinToString("\n")
140         if (whole == line) return
141         line = whole
142         around = buildList {
143             if (before != null) add(0 until before.length)
144             if (after != null) add(whole.length - after.length until whole.length)
145         }
146         text.setTypeface(Typeface.DEFAULT, Typeface.NORMAL)
147         select(null)
148     }
149 
150     /** Shows a word of the app to the user, where the line would be: no player, no file. */
151     fun showStatus(value: String) {
152         line = ""
153         around = emptyList()
154         selection = null
155         lookUpRow.visibility = View.GONE
156         text.setTypeface(Typeface.DEFAULT, Typeface.ITALIC)
157         text.text = value
158     }
159 
160     /** Selects the word at the point [x], [y] of the text view. For a tap, and for tests. */
161     fun selectAt(x: Float, y: Float, extend: Boolean = false) {
162         val offset = offsetAt(x, y) ?: return select(if (extend) selection else null)
163         val from = anchor
164         if (extend && from != null) {
165             select(Words.between(line, from, offset))
166         } else {
167             anchor = Words.at(line, offset)
168             select(anchor)
169         }
170     }
171 
172     private fun select(range: IntRange?) {
173         selection = range
174         if (range == null) anchor = null
175         val marked = SpannableString(line)
176         for (part in around) {
177             marked.setSpan(ForegroundColorSpan(Color.DKGRAY), part.first, part.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
178             marked.setSpan(RelativeSizeSpan(0.85f), part.first, part.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
179         }
180         if (range != null) {
181             marked.setSpan(BackgroundColorSpan(Color.BLACK), range.first, range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
182             marked.setSpan(ForegroundColorSpan(Color.WHITE), range.first, range.last + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
183         }
184         text.text = marked
185         lookUpRow.visibility = if (range == null) View.GONE else View.VISIBLE
186         if (range == null) events.onSelectionCleared()
187     }
188 
189     /** The index of the character under the point; null when the point is not on a character. */
190     private fun offsetAt(x: Float, y: Float): Int? {
191         val layout = text.layout ?: return null
192         if (line.isEmpty()) return null
193         val inX = x - text.totalPaddingLeft
194         val inY = y - text.totalPaddingTop
195         if (inY < 0 || inY > layout.height) return null
196         val row = layout.getLineForVertical(inY.toInt())
197         if (inX < layout.getLineLeft(row) || inX > layout.getLineRight(row)) return null
198         // The offset of a point is a place between two characters. The character under the
199         // finger is the one before that place when the finger is on its right half.
200         val between = layout.getOffsetForHorizontal(row, inX)
201         val under = if (between > layout.getLineStart(row) && layout.getPrimaryHorizontal(between) > inX) between - 1 else between
202         return under.coerceIn(0, line.length - 1)
203     }
204 
205     private fun selected(): String? = selection?.let { line.substring(it.first, it.last + 1) }
206 
207     private fun lookUp() {
208         events.onLookUp(selected() ?: return)
209     }
210 
211     private fun share() {
212         events.onShare(selected() ?: return)
213     }
214 
215     private fun anki() {
216         events.onAnki(selected() ?: return)
217     }
218 
219     private fun copy() {
220         val word = selected() ?: return
221         context.getSystemService(ClipboardManager::class.java).setPrimaryClip(ClipData.newPlainText("subtitle", word))
222     }
223 
224     @SuppressLint("ClickableViewAccessibility") // The buttons of the panel do the same for a screen reader.
225     private fun selectWith(view: TextView) {
226         view.setOnTouchListener { _, event ->
227             when (event.actionMasked) {
228                 MotionEvent.ACTION_DOWN -> {
229                     selectAt(event.x, event.y)
230                     if (selection != null) events.onTouchWord()
231                 }
232                 MotionEvent.ACTION_MOVE -> selectAt(event.x, event.y, extend = true)
233                 MotionEvent.ACTION_UP -> lookUp()
234             }
235             true
236         }
237     }
238 
239     @SuppressLint("ClickableViewAccessibility") // A drag has no equal for a screen reader; the panel works where it is.
240     private fun dragWith(handle: View) {
241         var lastX = 0f
242         var lastY = 0f
243         handle.setOnTouchListener { _, event ->
244             when (event.actionMasked) {
245                 MotionEvent.ACTION_DOWN -> {
246                     lastX = event.rawX
247                     lastY = event.rawY
248                 }
249                 MotionEvent.ACTION_MOVE -> {
250                     events.onDrag(event.rawX - lastX, event.rawY - lastY)
251                     lastX = event.rawX
252                     lastY = event.rawY
253                 }
254                 MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> events.onDragEnd()
255             }
256             true
257         }
258     }
259 
260     private fun toggle(row: View) {
261         row.isVisible = !row.isVisible
262     }
263 
264     private fun button(label: String, description: String?, onClick: () -> Unit) = Button(context).apply {
265         text = label
266         isAllCaps = false
267         contentDescription = description ?: label
268         minWidth = dp(44)
269         minimumWidth = dp(44)
270         setOnClickListener { onClick() }
271     }
272 
273     private fun wrap() = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
274 
275     private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
276 }