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/MainActivity.kt (12633 bytes)

1 package space.subread.overlay
2 
3 import android.annotation.SuppressLint
4 import android.app.Activity
5 import android.app.AlertDialog
6 import android.content.ComponentName
7 import android.content.Intent
8 import android.graphics.Color
9 import android.graphics.Typeface
10 import android.net.Uri
11 import android.os.Bundle
12 import android.provider.OpenableColumns
13 import android.provider.Settings
14 import android.text.SpannableString
15 import android.text.Spanned
16 import android.text.style.UnderlineSpan
17 import android.view.Gravity
18 import android.view.View
19 import android.widget.Button
20 import android.widget.LinearLayout
21 import android.widget.ScrollView
22 import android.widget.SeekBar
23 import android.widget.TextView
24 import android.widget.Toast
25 import androidx.core.net.toUri
26 import space.subread.overlay.core.Srt
27 
28 /**
29  * The one screen of the app: the two permissions, the subtitle file, the dictionary, the text
30  * size, and the button for the panel. Each part is on the screen from the start, so that the
31  * user sees what the app does before a button is pressed.
32  */
33 class MainActivity : Activity() {
34 
35     private lateinit var store: Store
36     private lateinit var content: LinearLayout
37 
38     override fun onCreate(savedInstanceState: Bundle?) {
39         super.onCreate(savedInstanceState)
40         store = Store(this)
41         content = LinearLayout(this).apply {
42             orientation = LinearLayout.VERTICAL
43             setPadding(dp(20), dp(20), dp(20), dp(20))
44             setBackgroundColor(Color.WHITE)
45         }
46         setContentView(ScrollView(this).apply {
47             fitsSystemWindows = true
48             setBackgroundColor(Color.WHITE)
49             addView(content)
50         })
51     }
52 
53     /** The user comes back from the settings of Android here, so the state is read again. */
54     override fun onResume() {
55         super.onResume()
56         draw()
57     }
58 
59     private fun draw() {
60         content.removeAllViews()
61         val overlay = Settings.canDrawOverlays(this)
62         val listener = MediaListener.isAllowed(this)
63 
64         title(getString(R.string.app_name))
65         note(getString(R.string.about))
66         note(getString(R.string.privacy))
67 
68         step(R.string.step_overlay, getString(R.string.step_overlay_why), if (overlay) null else getString(R.string.allow), overlay) {
69             startActivity(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, "package:$packageName".toUri()))
70         }
71         step(R.string.step_listener, getString(R.string.step_listener_why), if (listener) null else getString(R.string.allow), listener) {
72             startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
73         }
74         step(R.string.step_file, store.subtitlesName ?: getString(R.string.step_file_none), getString(R.string.choose)) {
75             startActivityForResult(
76                 Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*"),
77                 PICK_SUBTITLES,
78             )
79         }
80         step(R.string.step_dictionary, dictionaryName(), getString(R.string.choose)) { chooseDictionary() }
81         step(R.string.step_size, "${store.textSizeSp.toInt()} sp", null) {}
82         row(button("−") { size(-2f) }, button("+") { size(2f) })
83         step(R.string.step_transparency, getString(R.string.step_transparency_why), null) {}
84         content.addView(transparencySlider(), wide())
85         val around = store.linesAround
86         step(
87             R.string.step_lines,
88             getString(if (around) R.string.lines_three else R.string.lines_one),
89             getString(if (around) R.string.lines_show_one else R.string.lines_show_three),
90         ) {
91             store.linesAround = !around
92             MediaListener.instance?.reload()
93             draw()
94         }
95 
96         val shown = store.shown && MediaListener.instance != null
97         content.addView(button(getString(if (shown) R.string.hide else R.string.show)) { togglePanel() }.apply {
98             setTypeface(typeface, Typeface.BOLD)
99         }, wide(top = 24))
100 
101         note(getString(R.string.make_subtitles), top = 32)
102         val subread = packageManager.getLaunchIntentForPackage(SUBREAD_PACKAGE)
103         if (subread != null) {
104             content.addView(button(getString(R.string.open_subread)) { runCatching { startActivity(subread) } }, wide())
105         } else {
106             content.addView(button(getString(R.string.install_subread)) { open(SUBREAD_INSTALL) }, wide())
107         }
108         note(getString(R.string.anki_note), top = 24)
109         val anki = packageManager.getLaunchIntentForPackage(Anki.PACKAGE)
110         if (anki != null) {
111             content.addView(button(getString(R.string.open_anki)) { runCatching { startActivity(anki) } }, wide())
112         } else {
113             content.addView(button(getString(R.string.install_anki)) { open(Anki.INSTALL) }, wide())
114         }
115         if (BuildConfig.DONATE_LINK) content.addView(button(getString(R.string.donate)) { open(KOFI) }, wide())
116         // The Google Play build (-PplayStore=true) has no Ko-fi link, and no More apps either.
117         if (BuildConfig.DONATE_LINK) moreApps()
118     }
119 
120     /** The other sites and apps of the same author. A tap on an entry opens its page in the browser. */
121     private fun moreApps() {
122         content.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(32) })
123         content.addView(TextView(this).apply {
124             text = getString(R.string.more_apps)
125             textSize = 18f
126             setTypeface(typeface, Typeface.BOLD)
127             setTextColor(Color.BLACK)
128         }, wide(top = 12))
129         for (app in MORE_APPS) {
130             val name = getString(app.name)
131             content.addView(TextView(this).apply {
132                 text = SpannableString("$name\n${getString(app.line)}").apply {
133                     setSpan(UnderlineSpan(), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
134                 }
135                 textSize = 15f
136                 setTextColor(Color.BLACK)
137                 setOnClickListener { open(app.url) }
138             }, wide(top = 12))
139         }
140     }
141 
142     private fun togglePanel() {
143         val service = MediaListener.instance
144         when {
145             !Settings.canDrawOverlays(this) || !MediaListener.isAllowed(this) -> toast(R.string.need_both)
146             service == null -> toast(R.string.listener_starting)
147             store.shown -> service.hidePanel()
148             else -> service.showPanel()
149         }
150         draw()
151     }
152 
153     private fun size(change: Float) {
154         store.textSizeSp += change
155         MediaListener.instance?.reload()
156         draw()
157     }
158 
159     /** A slider from 0 % (a white panel) to 90 %. The panel changes while the finger moves. */
160     @SuppressLint("SetTextI18n")
161     private fun transparencySlider(): View {
162         val label = TextView(this).apply {
163             setTextColor(Color.BLACK)
164             textSize = 15f
165         }
166         val slider = SeekBar(this).apply {
167             max = 90
168             progress = store.transparencyPercent
169             setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
170                 override fun onProgressChanged(bar: SeekBar, value: Int, fromUser: Boolean) {
171                     label.text = "$value %"
172                     if (fromUser) {
173                         store.transparencyPercent = value
174                         MediaListener.instance?.reload()
175                     }
176                 }
177 
178                 override fun onStartTrackingTouch(bar: SeekBar) = Unit
179 
180                 override fun onStopTrackingTouch(bar: SeekBar) = Unit
181             })
182         }
183         label.text = "${slider.progress} %"
184         return LinearLayout(this).apply {
185             gravity = Gravity.CENTER_VERTICAL
186             addView(slider, LinearLayout.LayoutParams(0, -2, 1f))
187             addView(label, LinearLayout.LayoutParams(-2, -2).apply { leftMargin = dp(12) })
188         }
189     }
190 
191     private fun dictionaryName(): String {
192         val chosen = store.dictionary ?: return getString(R.string.dictionary_ask)
193         return dictionaries().firstOrNull { it.second == chosen }?.first ?: getString(R.string.dictionary_ask)
194     }
195 
196     /** The apps that take a selected text, with the name that each one gives the entry. */
197     private fun dictionaries(): List<Pair<String, ComponentName>> =
198         packageManager.queryIntentActivities(Lookup.probe(), 0).map {
199             val app = it.activityInfo.applicationInfo.loadLabel(packageManager)
200             val entry = it.loadLabel(packageManager)
201             val name = if (entry.toString() == app.toString()) "$app" else "$app: $entry"
202             name to ComponentName(it.activityInfo.packageName, it.activityInfo.name)
203         }.sortedBy { it.first }
204 
205     private fun chooseDictionary() {
206         val found = dictionaries()
207         if (found.isEmpty()) {
208             AlertDialog.Builder(this).setMessage(R.string.dictionary_none).setPositiveButton(android.R.string.ok, null).show()
209             return
210         }
211         val names = listOf(getString(R.string.dictionary_ask)) + found.map { it.first }
212         AlertDialog.Builder(this).setItems(names.toTypedArray()) { _, which ->
213             store.dictionary = found.getOrNull(which - 1)?.second
214             draw()
215         }.show()
216     }
217 
218     @Deprecated("The platform Activity has no other result API, and this app has no AndroidX activity.")
219     override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
220         val uri = data?.data
221         if (requestCode != PICK_SUBTITLES || resultCode != RESULT_OK || uri == null) return
222         // The listener reads the file later, also after a restart of the device.
223         runCatching { contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) }
224         val lines = runCatching {
225             contentResolver.openInputStream(uri)!!.use { Srt.parse(it.readBytes().decodeToString()) }.size
226         }.getOrDefault(0)
227         store.subtitles = uri
228         store.subtitlesName = resources.getQuantityString(R.plurals.step_file_cues, lines, displayName(uri), lines)
229         MediaListener.instance?.reload()
230         draw()
231     }
232 
233     private fun displayName(uri: Uri): String =
234         contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
235             if (it.moveToFirst()) it.getString(0) else null
236         } ?: uri.lastPathSegment.orEmpty()
237 
238     private fun open(link: String) {
239         runCatching { startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) }
240     }
241 
242     private fun toast(message: Int) = Toast.makeText(this, message, Toast.LENGTH_LONG).show()
243 
244     // The screen is built in code: six rows do not need a layout file each.
245 
246     private fun title(value: String) = content.addView(TextView(this).apply {
247         text = value
248         textSize = 26f
249         setTypeface(typeface, Typeface.BOLD)
250         setTextColor(Color.BLACK)
251     })
252 
253     private fun note(value: String, top: Int = 8) = content.addView(TextView(this).apply {
254         text = value
255         textSize = 15f
256         setTextColor(Color.BLACK)
257     }, wide(top))
258 
259     @SuppressLint("SetTextI18n")
260     private fun step(name: Int, detail: String, action: String?, done: Boolean = false, onAction: () -> Unit) {
261         content.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(16) })
262         content.addView(TextView(this).apply {
263             text = getString(name) + if (done) "  ✓ ${getString(R.string.allowed)}" else ""
264             textSize = 18f
265             setTypeface(typeface, Typeface.BOLD)
266             setTextColor(Color.BLACK)
267         }, wide(top = 12))
268         note(detail, top = 4)
269         if (action != null) content.addView(button(action, onAction), LinearLayout.LayoutParams(-2, -2))
270     }
271 
272     private fun row(vararg views: View) = content.addView(LinearLayout(this).apply { views.forEach { addView(it) } })
273 
274     private fun button(label: String, onClick: () -> Unit) = Button(this).apply {
275         text = label
276         isAllCaps = false
277         setOnClickListener { onClick() }
278     }
279 
280     private fun wide(top: Int = 8) = LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(top) }
281 
282     private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
283 
284     private companion object {
285         const val PICK_SUBTITLES = 1
286         const val SUBREAD_PACKAGE = "space.subread.app"
287         // The release page of the SubRead app. Each release has the APK as SubRead.apk.
288         const val SUBREAD_INSTALL = "https://github.com/equwal/subread-android/releases/latest"
289         const val KOFI = "https://ko-fi.com/truex"
290     }
291 }