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