Recently Written · git

subread-dictionary

Pop-up dictionary for Android that reads Yomitan dictionaries, with local audio

git clone https://github.com/equwal/subread-dictionary

Log | Files | Refs


app/src/main/kotlin/space/subread/dictionary/LookupActivity.kt (10894 bytes)

1 package space.subread.dictionary
2 
3 import android.annotation.SuppressLint
4 import android.app.Activity
5 import android.app.AlertDialog
6 import android.content.Intent
7 import android.graphics.Color
8 import android.graphics.Typeface
9 import android.os.Bundle
10 import android.text.Html
11 import android.text.SpannableString
12 import android.text.Spanned
13 import android.text.method.LinkMovementMethod
14 import android.text.style.BackgroundColorSpan
15 import android.view.Gravity
16 import android.view.MotionEvent
17 import android.view.View
18 import android.view.ViewGroup
19 import android.view.WindowManager
20 import android.widget.Button
21 import android.widget.LinearLayout
22 import android.widget.ScrollView
23 import android.widget.TextView
24 import android.widget.Toast
25 import space.subread.dictionary.core.Glossary
26 import space.subread.dictionary.core.Lookup
27 import space.subread.dictionary.core.Match
28 import kotlin.concurrent.thread
29 
30 /**
31  * The pop-up. It takes a text from the text selection menu, from the share sheet, or from an
32  * intent, and shows the terms at the scan position: the longest first. A tap on a character of
33  * the text moves the scan position there.
34  */
35 class LookupActivity : Activity() {
36 
37     private lateinit var store: Store
38     private lateinit var dictionaries: Dictionaries
39     private lateinit var player: AudioPlayer
40     private lateinit var textView: TextView
41     private lateinit var results: LinearLayout
42     private var text = ""
43     private var offset = 0
44     private var generation = 0
45 
46     // The touch listener finds the character under the finger; it calls performClick itself.
47     @SuppressLint("ClickableViewAccessibility")
48     override fun onCreate(savedInstanceState: Bundle?) {
49         super.onCreate(savedInstanceState)
50         store = Store(this)
51         dictionaries = Dictionaries(this)
52         player = AudioPlayer(this)
53 
54         // A panel at the bottom, over the app that sent the text. The height is fixed, so the
55         // panel does not grow with the results: they scroll inside it.
56         val height = (resources.displayMetrics.heightPixels * 0.6).toInt()
57         window.attributes = window.attributes.apply {
58             width = ViewGroup.LayoutParams.MATCH_PARENT
59             this.height = height
60             gravity = Gravity.BOTTOM
61         }
62         window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
63 
64         textView = TextView(this).apply {
65             textSize = store.textSizeSp + 3
66             setTextColor(Color.BLACK)
67             maxLines = 4
68             setPadding(dp(16), dp(12), dp(16), dp(8))
69             setOnTouchListener { view, event ->
70                 if (event.action == MotionEvent.ACTION_UP) {
71                     val at = (view as TextView).getOffsetForPosition(event.x, event.y)
72                     if (at in text.indices) scan(at)
73                     view.performClick()
74                 }
75                 true
76             }
77         }
78         results = LinearLayout(this).apply {
79             orientation = LinearLayout.VERTICAL
80             setPadding(dp(16), 0, dp(16), dp(16))
81         }
82         val bar = LinearLayout(this).apply {
83             gravity = Gravity.END
84             addView(smallButton(getString(R.string.settings)) { startActivity(Intent(this@LookupActivity, MainActivity::class.java)) })
85             addView(smallButton(getString(R.string.close)) { finish() })
86         }
87         setContentView(
88             LinearLayout(this).apply {
89                 orientation = LinearLayout.VERTICAL
90                 setBackgroundColor(Color.WHITE)
91                 addView(bar, LinearLayout.LayoutParams(-1, -2))
92                 addView(textView, LinearLayout.LayoutParams(-1, -2))
93                 addView(View(this@LookupActivity).apply { setBackgroundColor(Color.LTGRAY) }, LinearLayout.LayoutParams(-1, dp(1)))
94                 addView(ScrollView(this@LookupActivity).apply { addView(results) }, LinearLayout.LayoutParams(-1, 0, 1f))
95             },
96             ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height),
97         )
98         take(intent)
99     }
100 
101     override fun onNewIntent(intent: Intent) {
102         super.onNewIntent(intent)
103         setIntent(intent)
104         take(intent)
105     }
106 
107     override fun onDestroy() {
108         player.stop()
109         dictionaries.close()
110         super.onDestroy()
111     }
112 
113     /** The text of the intent, from whichever of the three ways in. */
114     private fun take(intent: Intent) {
115         text = (intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT) ?: intent.getCharSequenceExtra(Intent.EXTRA_TEXT) ?: "")
116             .toString().trim()
117         if (text.isEmpty()) {
118             Toast.makeText(this, R.string.no_text, Toast.LENGTH_SHORT).show()
119             finish()
120             return
121         }
122         scan(0)
123     }
124 
125     private fun scan(at: Int) {
126         offset = at
127         val shown = SpannableString(text)
128         textView.text = shown
129         val run = ++generation
130         thread {
131             val found = if (dictionaries.anyEnabled()) Lookup.find(text, at, dictionaries) else null
132             val metas = found.orEmpty().map { it.stored.term.expression }.distinct().associateWith { dictionaries.meta(it) }
133             runOnUiThread { if (run == generation) show(found, metas) }
134         }
135     }
136 
137     private fun show(found: List<Match>?, metas: Map<String, List<StoredMeta>>) {
138         results.removeAllViews()
139         if (found == null) {
140             note(getString(R.string.no_dictionaries))
141             return
142         }
143         if (found.isEmpty()) {
144             note(getString(R.string.no_results))
145             return
146         }
147         // The longest match is marked in the text.
148         val shown = SpannableString(text)
149         shown.setSpan(BackgroundColorSpan(0xFFFFE082.toInt()), offset, offset + found.first().length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
150         textView.text = shown
151 
152         // One card per expression and reading, with the entries of each dictionary inside it.
153         val groups = found.groupBy { it.stored.term.expression to it.stored.term.reading }
154         for ((key, matches) in groups) {
155             val (expression, reading) = key
156             results.addView(View(this).apply { setBackgroundColor(Color.LTGRAY) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(10) })
157             results.addView(LinearLayout(this).apply {
158                 gravity = Gravity.CENTER_VERTICAL
159                 addView(TextView(this@LookupActivity).apply {
160                     text = if (reading.isEmpty() || reading == expression) expression else "$expression【$reading】"
161                     textSize = store.textSizeSp + 5
162                     setTypeface(typeface, Typeface.BOLD)
163                     setTextColor(Color.BLACK)
164                 }, LinearLayout.LayoutParams(0, -2, 1f))
165                 addView(smallButton("▶ " + getString(R.string.audio_play)) { play(expression, reading, choose = false) }.apply {
166                     setOnLongClickListener { play(expression, reading, choose = true); true }
167                 })
168             }, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(6) })
169 
170             val reasons = matches.first().reasons
171             val meta = metas[expression].orEmpty().filter { m ->
172                 val forReading = when (m.meta.mode) { "freq" -> Glossary.frequencyReading(m.meta.data); "pitch" -> Glossary.pitchReading(m.meta.data); else -> null }
173                 forReading == null || forReading == reading || reading.isEmpty()
174             }
175             val line = buildList {
176                 if (reasons.isNotEmpty()) add(reasons.joinToString(" ‹ "))
177                 for (m in meta) when (m.meta.mode) {
178                     "freq" -> add("${m.dictionaryTitle}: ${Glossary.frequencyText(m.meta.data)}")
179                     "pitch" -> add(Glossary.pitchText(m.meta.data))
180                 }
181             }
182             if (line.isNotEmpty()) note(line.joinToString("   "), color = Color.DKGRAY, size = store.textSizeSp - 3)
183 
184             for (match in matches) {
185                 val term = match.stored.term
186                 val tags = (term.definitionTags.split(' ') + term.termTags.split(' ')).filter { it.isNotEmpty() }.distinct()
187                 note(
188                     listOf(match.stored.dictionaryTitle).plus(tags).joinToString("  "),
189                     color = Color.GRAY,
190                     size = store.textSizeSp - 4,
191                     top = 6,
192                 )
193                 results.addView(TextView(this).apply {
194                     text = Html.fromHtml(Glossary.toHtml(term.glossary), Html.FROM_HTML_MODE_COMPACT)
195                     textSize = store.textSizeSp
196                     setTextColor(Color.BLACK)
197                     movementMethod = LinkMovementMethod.getInstance()
198                 }, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(2) })
199             }
200         }
201     }
202 
203     /** Plays the first source that has audio: the local ones in their order, then the remote ones. */
204     private fun play(expression: String, reading: String, choose: Boolean) {
205         thread {
206             val sources = LocalAudio(store.localAudioFile, store.localSources.split(',').map { it.trim() }).sources(expression, reading) +
207                 if (store.remoteEnabled) RemoteAudio.sources(store.remoteUrls.lines().map { it.trim() }.filter { it.isNotEmpty() }, expression, reading) else emptyList()
208             if (choose) {
209                 runOnUiThread {
210                     if (sources.isEmpty()) toast(getString(R.string.no_audio)) else {
211                         AlertDialog.Builder(this).setItems(sources.map { it.name }.toTypedArray()) { _, i -> thread { playFirst(listOf(sources[i])) } }.show()
212                     }
213                 }
214             } else {
215                 playFirst(sources)
216             }
217         }
218     }
219 
220     private fun playFirst(sources: List<AudioSource>) {
221         for (source in sources) {
222             val bytes = runCatching { source.load() }.getOrNull() ?: continue
223             runOnUiThread {
224                 runCatching { player.play(bytes) }.onFailure { toast(getString(R.string.audio_failed, it.message ?: "")) }
225             }
226             return
227         }
228         runOnUiThread { toast(getString(R.string.no_audio)) }
229     }
230 
231     private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
232 
233     private fun note(value: String, color: Int = Color.BLACK, size: Float = store.textSizeSp, top: Int = 8) = results.addView(
234         TextView(this).apply {
235             text = value
236             textSize = size
237             setTextColor(color)
238         },
239         LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(top) },
240     )
241 
242     private fun smallButton(label: String, onClick: () -> Unit) = Button(this).apply {
243         text = label
244         isAllCaps = false
245         textSize = 13f
246         setOnClickListener { onClick() }
247     }
248 
249     private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
250 }