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 }