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