fix: resolve emulator crash - thread safety and icon fallback

- NativeSearch: move onResult/onError callbacks outside withContext(Dispatchers.IO) to prevent CalledFromWrongThreadException
- SearchStrategy: change callback types to suspend to enable proper coroutine chaining
- SearchCoordinator: remove leaked CoroutineScope, rely on suspend callback chaining for fallback flow
- Resources: add mipmap-hdpi/mdpi/xhdpi/xxhdpi icon fallbacks for API < 26 devices
This commit is contained in:
xiaji
2026-05-24 21:09:05 +08:00
commit 98d05aa90a
57 changed files with 2366 additions and 0 deletions

View File

@@ -0,0 +1,237 @@
package com.videoapp.tv.ui
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.videoapp.tv.R
import com.videoapp.tv.PlayerActivity
import com.videoapp.tv.SettingsActivity
import com.videoapp.tv.data.AppDatabase
import com.videoapp.tv.data.SearchHistory
import com.videoapp.tv.data.SearchResult
import com.videoapp.tv.engine.SearchCoordinator
import kotlinx.coroutines.launch
class SearchFragment : Fragment() {
private lateinit var searchInput: EditText
private lateinit var btnSearch: Button
private lateinit var btnSettings: Button
private lateinit var historyContainer: ViewGroup
private lateinit var historyList: ViewGroup
private lateinit var btnClearHistory: Button
private lateinit var resultsGrid: RecyclerView
private lateinit var loadingProgress: View
private lateinit var statusText: TextView
private lateinit var fallbackWebView: WebView
private val searchCoordinator by lazy { SearchCoordinator(requireContext()) }
private val historyDao by lazy { AppDatabase.getInstance(requireContext()).searchHistoryDao() }
private val adapter by lazy {
SearchResultAdapter(
onItemClick = { result -> openPlayer(result) },
onItemFocus = { view -> view.animate().scaleX(1.05f).scaleY(1.05f).setDuration(150).start() }
)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(R.layout.fragment_search, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
searchInput = view.findViewById(R.id.search_input)
btnSearch = view.findViewById(R.id.btn_search)
btnSettings = view.findViewById(R.id.btn_settings)
historyContainer = view.findViewById(R.id.history_container)
historyList = view.findViewById(R.id.history_list)
btnClearHistory = view.findViewById(R.id.btn_clear_history)
resultsGrid = view.findViewById(R.id.results_grid)
loadingProgress = view.findViewById(R.id.loading_progress)
statusText = view.findViewById(R.id.status_text)
fallbackWebView = view.findViewById(R.id.fallback_webview)
setupResultsGrid()
setupListeners()
loadHistory()
}
private fun setupResultsGrid() {
resultsGrid.layoutManager = GridLayoutManager(context, 4)
resultsGrid.adapter = adapter
}
private fun setupListeners() {
searchInput.setOnEditorActionListener { _, actionId, event ->
if (actionId == EditorInfo.IME_ACTION_SEARCH ||
(event?.keyCode == KeyEvent.KEYCODE_ENTER && event.action == KeyEvent.ACTION_DOWN)
) {
performSearch(searchInput.text.toString().trim())
true
} else {
false
}
}
btnSearch.setOnClickListener {
performSearch(searchInput.text.toString().trim())
}
btnSettings.setOnClickListener {
startActivity(Intent(requireContext(), SettingsActivity::class.java))
}
btnClearHistory.setOnClickListener {
lifecycleScope.launch {
historyDao.clearAll()
showHistory(emptyList())
}
}
searchInput.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
historyContainer.visibility = View.VISIBLE
}
}
}
private fun performSearch(keyword: String) {
if (keyword.isEmpty()) return
hideFallbackWebView()
showLoading(true)
statusText.visibility = View.GONE
historyContainer.visibility = View.GONE
lifecycleScope.launch {
// Save to history
val existing = historyDao.findByKeyword(keyword)
if (existing != null) {
historyDao.updateTime(existing.id, System.currentTimeMillis())
} else {
historyDao.insert(SearchHistory(keyword = keyword))
}
}
lifecycleScope.launch {
searchCoordinator.search(
keyword = keyword,
onResult = { results ->
showLoading(false)
adapter.setItems(results)
if (results.isEmpty()) {
showStatus("未找到结果")
}
},
onError = { error ->
showLoading(false)
if (error == "FALLBACK_WEBVIEW") {
showFallbackWebView(keyword)
} else {
showStatus("搜索失败,请重试")
Toast.makeText(requireContext(), error, Toast.LENGTH_LONG).show()
}
},
onFallbackToWebView = {
showFallbackWebView(keyword)
}
)
}
}
private fun showFallbackWebView(keyword: String) {
val config = searchCoordinator.getConfig()
val base = config.baseUrl.trimEnd('/')
val path = config.searchPath.trimStart('/')
val params = "${config.keywordParam}=${java.net.URLEncoder.encode(keyword, "UTF-8")}"
val extraParams = config.extraParams.entries.joinToString("&") { "${it.key}=${it.value}" }
val url = "$base/$path?$params&$extraParams"
fallbackWebView.visibility = View.VISIBLE
resultsGrid.visibility = View.GONE
fallbackWebView.settings.javaScriptEnabled = true
fallbackWebView.webViewClient = WebViewClient()
fallbackWebView.loadUrl(url)
}
private fun hideFallbackWebView() {
fallbackWebView.visibility = View.GONE
fallbackWebView.stopLoading()
resultsGrid.visibility = View.VISIBLE
}
private fun showLoading(show: Boolean) {
loadingProgress.visibility = if (show) View.VISIBLE else View.GONE
}
private fun showStatus(msg: String) {
statusText.text = msg
statusText.visibility = View.VISIBLE
}
private fun openPlayer(result: SearchResult) {
val intent = Intent(requireContext(), PlayerActivity::class.java).apply {
putExtra("detail_url", result.detailUrl)
putExtra("title", result.title)
putExtra("category", result.category)
}
startActivity(intent)
}
private fun loadHistory() {
lifecycleScope.launch {
historyDao.getRecentHistory().collect { list ->
showHistory(list)
}
}
}
private fun showHistory(list: List<SearchHistory>) {
historyList.removeAllViews()
if (list.isEmpty()) {
historyContainer.visibility = View.GONE
return
}
for (item in list) {
val chip = Button(requireContext()).apply {
text = item.keyword
setTextColor(resources.getColor(R.color.text_primary, null))
setBackgroundResource(R.drawable.history_chip_selector)
textSize = 14f
setPadding(24, 8, 24, 8)
isFocusable = true
isFocusableInTouchMode = true
setOnClickListener {
searchInput.setText(item.keyword)
performSearch(item.keyword)
}
}
historyList.addView(chip)
}
}
override fun onDestroyView() {
super.onDestroyView()
fallbackWebView.destroy()
}
}