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 }