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,19 @@
package com.videoapp.tv
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.videoapp.tv.ui.SearchFragment
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.fragment_container, SearchFragment())
.commit()
}
}
}

View File

@@ -0,0 +1,226 @@
package com.videoapp.tv
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import com.videoapp.tv.data.ConfigRepository
import com.videoapp.tv.engine.Episode
import com.videoapp.tv.engine.VideoExtractor
import com.videoapp.tv.engine.WebViewPlayer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class PlayerActivity : AppCompatActivity() {
private lateinit var playerView: PlayerView
private lateinit var playerWebView: WebView
private lateinit var episodePanel: View
private lateinit var episodeList: android.widget.LinearLayout
private lateinit var loadingIndicator: View
private lateinit var errorText: android.widget.TextView
private var exoPlayer: ExoPlayer? = null
private val videoExtractor = VideoExtractor()
private val configRepo by lazy { ConfigRepository(this) }
private val webViewPlayer by lazy { WebViewPlayer(this) }
private var episodes: List<Episode> = emptyList()
private var currentEpisodeIndex = 0
private var isPlayerReady = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_player)
playerView = findViewById(R.id.player_view)
playerWebView = findViewById(R.id.player_webview)
episodePanel = findViewById(R.id.episode_panel)
episodeList = findViewById(R.id.episode_list)
loadingIndicator = findViewById(R.id.loading_indicator)
errorText = findViewById(R.id.error_text)
val detailUrl = intent.getStringExtra("detail_url") ?: ""
val title = intent.getStringExtra("title") ?: ""
initExoPlayer()
loadVideos(detailUrl, title)
setupFullscreenListener()
}
private fun initExoPlayer() {
exoPlayer = ExoPlayer.Builder(this).build().also { player ->
playerView.player = player
}
}
private fun loadVideos(detailUrl: String, title: String) {
showLoading(true)
val config = configRepo.getConfig()
lifecycleScope.launch {
try {
val videoInfo = videoExtractor.extractVideos(detailUrl, config)
episodes = videoInfo.episodes
if (videoInfo.episodes.isNotEmpty()) {
buildEpisodeUI(videoInfo.episodes)
playEpisode(videoInfo.episodes.first())
} else {
episodePanel.visibility = View.GONE
tryPlayDirectly(detailUrl, config)
}
} catch (e: Exception) {
showError("加载视频失败: ${e.message}")
showLoading(false)
}
}
}
private fun buildEpisodeUI(eps: List<Episode>) {
episodeList.removeAllViews()
episodePanel.visibility = View.VISIBLE
eps.forEachIndexed { index, ep ->
val btn = Button(this).apply {
text = ep.title
setBackgroundResource(R.drawable.episode_selector)
setTextColor(resources.getColor(R.color.text_primary, null))
textSize = 13f
minWidth = 0
setPadding(16, 8, 16, 8)
isFocusable = true
isFocusableInTouchMode = true
setOnClickListener {
currentEpisodeIndex = index
highlightEpisode(it)
playEpisode(ep)
}
}
episodeList.addView(btn)
}
}
private fun playEpisode(ep: Episode) {
showLoading(true)
val config = configRepo.getConfig()
lifecycleScope.launch {
val (directUrl, iframeUrl) = videoExtractor.extractFromPlayPage(ep.playUrl, config)
if (directUrl != null) {
// Play with ExoPlayer
playWithExoPlayer(directUrl)
} else if (iframeUrl != null) {
// Play iframe in WebView
playWithWebView(iframeUrl)
} else {
// Fallback: load play page in WebView
playWithWebView(ep.playUrl)
}
}
}
private fun tryPlayDirectly(detailUrl: String, config: com.videoapp.tv.data.SiteConfig) {
lifecycleScope.launch {
val (directUrl, iframeUrl) = videoExtractor.extractFromPlayPage(detailUrl, config)
if (directUrl != null) {
playWithExoPlayer(directUrl)
} else if (iframeUrl != null) {
playWithWebView(iframeUrl)
} else {
playWithWebView(detailUrl)
}
}
}
private fun playWithExoPlayer(url: String) {
playerWebView.visibility = View.GONE
playerView.visibility = View.VISIBLE
showLoading(false)
val mediaItem = MediaItem.fromUri(url)
exoPlayer?.apply {
setMediaItem(mediaItem)
prepare()
playWhenReady = true
}
}
private fun playWithWebView(url: String) {
playerView.visibility = View.GONE
playerWebView.visibility = View.VISIBLE
showLoading(false)
playerWebView.settings.javaScriptEnabled = true
playerWebView.settings.domStorageEnabled = true
playerWebView.settings.mediaPlaybackRequiresUserGesture = false
playerWebView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
showLoading(false)
}
}
playerWebView.loadUrl(url)
}
private fun highlightEpisode(view: View) {
for (i in 0 until episodeList.childCount) {
episodeList.getChildAt(i).isSelected = (episodeList.getChildAt(i) == view)
}
}
private fun showLoading(show: Boolean) {
loadingIndicator.visibility = if (show) View.VISIBLE else View.GONE
}
private fun showError(msg: String) {
errorText.text = msg
errorText.visibility = View.VISIBLE
showLoading(false)
}
private fun setupFullscreenListener() {
// Toggle episode panel visibility on dpad up/down
var panelVisible = true
playerView.setOnClickListener {
panelVisible = !panelVisible
episodePanel.visibility = if (panelVisible) View.VISIBLE else View.GONE
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
return when (keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> {
episodePanel.visibility = View.VISIBLE
true
}
KeyEvent.KEYCODE_DPAD_DOWN -> {
if (episodePanel.visibility == View.VISIBLE) {
episodePanel.visibility = View.GONE
}
true
}
else -> super.onKeyDown(keyCode, event)
}
}
override fun onPause() {
super.onPause()
exoPlayer?.playWhenReady = false
}
override fun onDestroy() {
super.onDestroy()
exoPlayer?.release()
exoPlayer = null
}
}

View File

@@ -0,0 +1,100 @@
package com.videoapp.tv
import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.videoapp.tv.data.ConfigRepository
import com.videoapp.tv.data.SiteConfig
class SettingsActivity : AppCompatActivity() {
private lateinit var configRepo: ConfigRepository
private lateinit var editBaseUrl: EditText
private lateinit var editSearchPath: EditText
private lateinit var editSearchMethod: EditText
private lateinit var editKeywordParam: EditText
private lateinit var editExtraParams: EditText
private lateinit var editResultSelector: EditText
private lateinit var editTitleSelector: EditText
private lateinit var editCoverSelector: EditText
private lateinit var editLinkSelector: EditText
private lateinit var btnSave: Button
private lateinit var btnRestore: Button
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
configRepo = ConfigRepository(this)
editBaseUrl = findViewById(R.id.edit_base_url)
editSearchPath = findViewById(R.id.edit_search_path)
editSearchMethod = findViewById(R.id.edit_search_method)
editKeywordParam = findViewById(R.id.edit_keyword_param)
editExtraParams = findViewById(R.id.edit_extra_params)
editResultSelector = findViewById(R.id.edit_result_selector)
editTitleSelector = findViewById(R.id.edit_title_selector)
editCoverSelector = findViewById(R.id.edit_cover_selector)
editLinkSelector = findViewById(R.id.edit_link_selector)
btnSave = findViewById(R.id.btn_save)
btnRestore = findViewById(R.id.btn_restore)
loadConfig()
btnSave.setOnClickListener { saveConfig() }
btnRestore.setOnClickListener {
configRepo.restoreDefault()
loadConfig()
Toast.makeText(this, R.string.config_restored, Toast.LENGTH_SHORT).show()
}
}
private fun loadConfig() {
val config = configRepo.getConfig()
editBaseUrl.setText(config.baseUrl)
editSearchPath.setText(config.searchPath)
editSearchMethod.setText(config.searchMethod)
editKeywordParam.setText(config.keywordParam)
editExtraParams.setText(
config.extraParams.entries.joinToString("&") { "${it.key}=${it.value}" }
)
editResultSelector.setText(config.resultSelector)
editTitleSelector.setText(config.titleSelector)
editCoverSelector.setText(config.coverSelector)
editLinkSelector.setText(config.linkSelector)
}
private fun saveConfig() {
val extraParams = parseExtraParams(editExtraParams.text.toString())
val config = SiteConfig(
baseUrl = editBaseUrl.text.toString().trim(),
searchPath = editSearchPath.text.toString().trim(),
searchMethod = editSearchMethod.text.toString().trim(),
keywordParam = editKeywordParam.text.toString().trim(),
extraParams = extraParams,
resultSelector = editResultSelector.text.toString().trim(),
titleSelector = editTitleSelector.text.toString().trim(),
coverSelector = editCoverSelector.text.toString().trim(),
linkSelector = editLinkSelector.text.toString().trim()
)
configRepo.saveConfig(config)
Toast.makeText(this, R.string.config_saved, Toast.LENGTH_SHORT).show()
finish()
}
private fun parseExtraParams(paramStr: String): Map<String, String> {
val map = mutableMapOf<String, String>()
if (paramStr.isBlank()) return map
paramStr.split("&").forEach { pair ->
val parts = pair.split("=", limit = 2)
if (parts.size == 2) {
map[parts[0].trim()] = parts[1].trim()
}
}
return map
}
}

View File

@@ -0,0 +1,28 @@
package com.videoapp.tv.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [SearchHistory::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun searchHistoryDao(): SearchHistoryDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"video_search_tv.db"
).build()
INSTANCE = instance
instance
}
}
}
}

View File

@@ -0,0 +1,36 @@
package com.videoapp.tv.data
import android.content.Context
import android.content.SharedPreferences
import com.google.gson.Gson
class ConfigRepository(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("site_config", Context.MODE_PRIVATE)
private val gson = Gson()
fun getConfig(): SiteConfig {
val json = prefs.getString(KEY_CONFIG, null)
return if (json != null) {
try {
gson.fromJson(json, SiteConfig::class.java)
} catch (e: Exception) {
SiteConfig.default()
}
} else {
SiteConfig.default()
}
}
fun saveConfig(config: SiteConfig) {
prefs.edit().putString(KEY_CONFIG, gson.toJson(config)).apply()
}
fun restoreDefault() {
saveConfig(SiteConfig.default())
}
companion object {
private const val KEY_CONFIG = "site_config_json"
}
}

View File

@@ -0,0 +1,11 @@
package com.videoapp.tv.data
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "search_history")
data class SearchHistory(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val keyword: String,
val searchTime: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,28 @@
package com.videoapp.tv.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface SearchHistoryDao {
@Query("SELECT * FROM search_history ORDER BY searchTime DESC LIMIT 50")
fun getRecentHistory(): Flow<List<SearchHistory>>
@Query("SELECT * FROM search_history WHERE keyword = :keyword LIMIT 1")
suspend fun findByKeyword(keyword: String): SearchHistory?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(history: SearchHistory)
@Query("UPDATE search_history SET searchTime = :time WHERE id = :id")
suspend fun updateTime(id: Long, time: Long)
@Query("DELETE FROM search_history WHERE id = :id")
suspend fun delete(id: Long)
@Query("DELETE FROM search_history")
suspend fun clearAll()
}

View File

@@ -0,0 +1,9 @@
package com.videoapp.tv.data
data class SearchResult(
val title: String,
val coverUrl: String,
val detailUrl: String,
val category: String,
val date: String
)

View File

@@ -0,0 +1,63 @@
package com.videoapp.tv.data
import com.google.gson.annotations.SerializedName
data class SiteConfig(
@SerializedName("base_url")
val baseUrl: String = "https://www.xb6v.com",
@SerializedName("search_path")
val searchPath: String = "/e/search/11index.php",
@SerializedName("search_method")
val searchMethod: String = "POST",
@SerializedName("keyword_param")
val keywordParam: String = "keyboard",
@SerializedName("extra_params")
val extraParams: Map<String, String> = mapOf(
"show" to "title",
"tempid" to "1",
"tbname" to "article",
"mid" to "1",
"dopost" to "search"
),
@SerializedName("result_selector")
val resultSelector: String = "li.post",
@SerializedName("title_selector")
val titleSelector: String = "h2 a",
@SerializedName("cover_selector")
val coverSelector: String = ".thumbnail img",
@SerializedName("link_selector")
val linkSelector: String = ".thumbnail a",
@SerializedName("category_selector")
val categorySelector: String = ".info_category a",
@SerializedName("date_selector")
val dateSelector: String = ".info_date",
@SerializedName("episode_selector")
val episodeSelector: String = "a.lBtn",
@SerializedName("iframe_selector")
val iframeSelector: String = ".video iframe",
@SerializedName("video_selector")
val videoSelector: String = "video source, video[src]"
) {
fun getFullSearchUrl(): String {
val base = baseUrl.trimEnd('/')
val path = searchPath.trimStart('/')
return "$base/$path"
}
companion object {
fun default() = SiteConfig()
}
}

View File

@@ -0,0 +1,84 @@
package com.videoapp.tv.engine
import com.videoapp.tv.data.SearchResult
import com.videoapp.tv.data.SiteConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Connection
import org.jsoup.Jsoup
class NativeSearch : SearchStrategy {
override fun getName() = "NativeSearch"
override suspend fun search(
keyword: String,
config: SiteConfig,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
) {
try {
val results = withContext(Dispatchers.IO) {
val url = config.getFullSearchUrl()
val connection: Connection = Jsoup.connect(url)
.data(config.keywordParam, keyword)
.timeout(15000)
config.extraParams.forEach { (key, value) ->
connection.data(key, value)
}
val method = config.searchMethod.uppercase()
val doc = if (method == "GET") {
connection.get()
} else {
connection.post()
}
val items = doc.select(config.resultSelector)
val resultsList = mutableListOf<SearchResult>()
for (item in items) {
try {
val titleEl = item.selectFirst(config.titleSelector) ?: continue
val linkEl = item.selectFirst(config.linkSelector)
val coverEl = item.selectFirst(config.coverSelector)
val categoryEl = item.selectFirst(config.categorySelector)
val dateEl = item.selectFirst(config.dateSelector)
val title = titleEl.text().trim()
val detailUrl = linkEl?.attr("href")?.trim() ?: ""
val coverUrl = coverEl?.attr("src")?.trim() ?: ""
val category = categoryEl?.text()?.trim() ?: ""
val date = dateEl?.text()?.trim() ?: ""
if (title.isNotEmpty() && detailUrl.isNotEmpty()) {
resultsList.add(
SearchResult(
title = title,
coverUrl = if (coverUrl.startsWith("http")) coverUrl
else config.baseUrl.trimEnd('/') + "/" + coverUrl.trimStart('/'),
detailUrl = if (detailUrl.startsWith("http")) detailUrl
else config.baseUrl.trimEnd('/') + "/" + detailUrl.trimStart('/'),
category = category,
date = date
)
)
}
} catch (e: Exception) {
// skip malformed items
}
}
resultsList
}
if (results.isEmpty()) {
onError("未找到结果")
} else {
onResult(results)
}
} catch (e: Exception) {
onError("NativeSearch 失败: ${e.message}")
}
}
}

View File

@@ -0,0 +1,55 @@
package com.videoapp.tv.engine
import android.content.Context
import com.videoapp.tv.data.ConfigRepository
import com.videoapp.tv.data.SearchResult
class SearchCoordinator(private val context: Context) {
private val configRepo = ConfigRepository(context)
private val nativeSearch = NativeSearch()
private val webViewSearch = WebViewSearch(context)
suspend fun search(
keyword: String,
onResult: (List<SearchResult>) -> Unit,
onError: (String) -> Unit,
onFallbackToWebView: (() -> Unit)? = null
) {
val config = configRepo.getConfig()
var completed = false
nativeSearch.search(
keyword = keyword,
config = config,
onResult = { result ->
if (!completed) {
completed = true
onResult(result)
}
},
onError = { error ->
if (!completed) {
webViewSearch.search(
keyword = keyword,
config = config,
onResult = { result ->
if (!completed) {
completed = true
onResult(result)
}
},
onError = { error2 ->
if (!completed) {
completed = true
onFallbackToWebView?.invoke()
onError("搜索失败: $error / $error2")
}
}
)
}
}
)
}
fun getConfig() = configRepo.getConfig()
}

View File

@@ -0,0 +1,15 @@
package com.videoapp.tv.engine
import com.videoapp.tv.data.SearchResult
import com.videoapp.tv.data.SiteConfig
interface SearchStrategy {
suspend fun search(
keyword: String,
config: SiteConfig,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
)
fun getName(): String
}

View File

@@ -0,0 +1,113 @@
package com.videoapp.tv.engine
import com.videoapp.tv.data.SiteConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
data class VideoInfo(
val detailTitle: String,
val isSeries: Boolean,
val episodes: List<Episode>,
val videoUrl: String?,
val iframeUrl: String?
)
data class Episode(
val title: String,
val playUrl: String
)
class VideoExtractor {
suspend fun extractVideos(
detailUrl: String,
config: SiteConfig
): VideoInfo = withContext(Dispatchers.IO) {
val doc = Jsoup.connect(detailUrl).timeout(15000).get()
val pageTitle = doc.title()
val episodes = mutableListOf<Episode>()
var iframeUrl: String? = null
var directVideoUrl: String? = null
// Extract episodes
val episodeEls = doc.select(config.episodeSelector)
for (ep in episodeEls) {
val title = ep.text().trim()
val href = ep.attr("href")?.trim() ?: ""
if (title.isNotEmpty() && href.isNotEmpty()) {
val fullUrl = if (href.startsWith("http")) href
else config.baseUrl.trimEnd('/') + "/" + href.trimStart('/')
episodes.add(Episode(title, fullUrl))
}
}
val isSeries = episodes.size > 1
// If episodes found, try to extract video from first episode's play page
if (episodes.isNotEmpty()) {
val playUrl = episodes.first().playUrl
try {
val playDoc = Jsoup.connect(playUrl).timeout(15000).get()
// Try iframe first
val iframeEl = playDoc.selectFirst(config.iframeSelector)
if (iframeEl != null) {
iframeUrl = iframeEl.attr("src")
if (iframeUrl != null && iframeUrl.startsWith("//")) {
iframeUrl = "https:$iframeUrl"
}
}
// Try direct video tag
val videoSrc = playDoc.selectFirst(config.videoSelector)
if (videoSrc != null) {
directVideoUrl = videoSrc.attr("src")
}
} catch (e: Exception) {
// extraction failed, will fallback to WebView
}
} else {
// No episodes found - might be a movie (single play button)
// Try to extract video directly from the detail page
val iframeEl = doc.selectFirst(config.iframeSelector)
if (iframeEl != null) {
iframeUrl = iframeEl.attr("src")
if (iframeUrl != null && iframeUrl.startsWith("//")) {
iframeUrl = "https:$iframeUrl"
}
}
}
VideoInfo(
detailTitle = pageTitle,
isSeries = isSeries,
episodes = episodes,
videoUrl = directVideoUrl,
iframeUrl = iframeUrl
)
}
suspend fun extractFromPlayPage(
playUrl: String,
config: SiteConfig
): Pair<String?, String?> = withContext(Dispatchers.IO) {
try {
val doc = Jsoup.connect(playUrl).timeout(15000).get()
val iframeEl = doc.selectFirst(config.iframeSelector)
var iframeUrl = iframeEl?.attr("src")
if (iframeUrl != null && iframeUrl.startsWith("//")) {
iframeUrl = "https:$iframeUrl"
}
val videoSrc = doc.selectFirst(config.videoSelector)
val directUrl = videoSrc?.attr("src")
Pair(directUrl, iframeUrl)
} catch (e: Exception) {
Pair(null, null)
}
}
}

View File

@@ -0,0 +1,24 @@
package com.videoapp.tv.engine
import android.content.Context
import android.webkit.WebView
import com.videoapp.tv.data.SearchResult
import com.videoapp.tv.data.SiteConfig
class WebViewDirect(private val context: Context) : SearchStrategy {
private var webView: WebView? = null
override fun getName() = "WebViewDirect"
override suspend fun search(
keyword: String,
config: SiteConfig,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
) {
onError("FALLBACK_WEBVIEW")
}
fun getWebView(): WebView? = webView
}

View File

@@ -0,0 +1,88 @@
package com.videoapp.tv.engine
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.view.View
import android.view.ViewGroup
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
class WebViewPlayer(private val context: Context) {
@SuppressLint("SetJavaScriptEnabled")
fun createPlayerWebView(container: FrameLayout): WebView {
val webView = WebView(context)
webView.layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
webView.setBackgroundColor(Color.BLACK)
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
allowFileAccess = true
allowContentAccess = true
useWideViewPort = true
loadWithOverviewMode = true
setSupportZoom(true)
builtInZoomControls = true
displayZoomControls = false
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
// Inject JS to auto-play and hide ads
val js = """
(function() {
// Auto play video elements
var videos = document.querySelectorAll('video');
videos.forEach(function(v) { v.play(); });
// Remove common ad elements
var ads = document.querySelectorAll('.advertisement, .ad, [class*="ad-"], #ad, [id*="ad"]');
ads.forEach(function(a) { a.style.display = 'none'; });
})();
""".trimIndent()
view.evaluateJavascript(js, null)
}
}
webView.webChromeClient = object : WebChromeClient() {
private var customView: View? = null
private var customViewCallback: CustomViewCallback? = null
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
if (customView != null) {
callback.onCustomViewHidden()
return
}
customView = view
customViewCallback = callback
container.addView(view, FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
))
webView.visibility = ViewGroup.GONE
}
override fun onHideCustomView() {
customView?.let { container.removeView(it) }
customView = null
customViewCallback?.onCustomViewHidden()
customViewCallback = null
webView.visibility = ViewGroup.VISIBLE
}
}
container.addView(webView)
return webView
}
}

View File

@@ -0,0 +1,133 @@
package com.videoapp.tv.engine
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import com.videoapp.tv.data.SearchResult
import com.videoapp.tv.data.SiteConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
import kotlin.coroutines.resume
class WebViewSearch(context: Context) : SearchStrategy {
private val appContext = context.applicationContext
override fun getName() = "WebViewSearch"
override suspend fun search(
keyword: String,
config: SiteConfig,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
) {
withContext(Dispatchers.Main) {
val result = withTimeoutOrNull(20000L) {
suspendCancellableCoroutine<List<SearchResult>> { cont ->
val webView = WebView(appContext)
webView.settings.javaScriptEnabled = true
val jsInterface = object {
@JavascriptInterface
fun onResult(json: String) {
val list = parseJson(json, config)
if (cont.isActive) cont.resume(list)
webView.destroy()
}
@JavascriptInterface
@Suppress("unused")
fun onError(msg: String) {
if (cont.isActive) cont.resume(emptyList())
webView.destroy()
}
}
webView.addJavascriptInterface(jsInterface, "VideoApp")
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
val js = buildJsExtractionScript(config)
view.evaluateJavascript(js, null)
}
}
val searchUrl = buildSearchUrl(config, keyword)
webView.loadUrl(searchUrl)
cont.invokeOnCancellation {
webView.destroy()
}
}
}
if (result == null || result.isEmpty()) {
onError("WebViewSearch 失败: 未提取到数据")
} else {
onResult(result)
}
}
}
private fun buildSearchUrl(config: SiteConfig, keyword: String): String {
val base = config.baseUrl.trimEnd('/')
val path = config.searchPath.trimStart('/')
val params = StringBuilder()
params.append("${config.keywordParam}=${java.net.URLEncoder.encode(keyword, "UTF-8")}")
config.extraParams.forEach { (k, v) ->
params.append("&${k}=${java.net.URLEncoder.encode(v, "UTF-8")}")
}
return "$base/$path?$params"
}
private fun buildJsExtractionScript(config: SiteConfig): String {
return """
(function() {
try {
var results = [];
var items = document.querySelectorAll('${config.resultSelector}');
items.forEach(function(item) {
var titleEl = item.querySelector('${config.titleSelector}');
var linkEl = item.querySelector('${config.linkSelector}');
var coverEl = item.querySelector('${config.coverSelector}');
var catEl = item.querySelector('${config.categorySelector}');
var dateEl = item.querySelector('${config.dateSelector}');
results.push({
title: titleEl ? titleEl.textContent.trim() : '',
link: linkEl ? linkEl.href : '',
cover: coverEl ? coverEl.src : '',
category: catEl ? catEl.textContent.trim() : '',
date: dateEl ? dateEl.textContent.trim() : ''
});
});
VideoApp.onResult(JSON.stringify(results));
} catch(e) {
VideoApp.onError(e.message);
}
})();
""".trimIndent()
}
private fun parseJson(json: String, @Suppress("UNUSED_PARAMETER") config: SiteConfig): List<SearchResult> {
return try {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
SearchResult(
title = obj.optString("title", ""),
coverUrl = obj.optString("cover", ""),
detailUrl = obj.optString("link", ""),
category = obj.optString("category", ""),
date = obj.optString("date", "")
)
}.filter { it.title.isNotEmpty() && it.detailUrl.isNotEmpty() }
} catch (e: Exception) {
emptyList()
}
}
}

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()
}
}

View File

@@ -0,0 +1,66 @@
package com.videoapp.tv.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.videoapp.tv.R
import com.videoapp.tv.data.SearchResult
class SearchResultAdapter(
private val onItemClick: (SearchResult) -> Unit,
private val onItemFocus: (View) -> Unit
) : RecyclerView.Adapter<SearchResultAdapter.ViewHolder>() {
private var items: List<SearchResult> = emptyList()
fun setItems(list: List<SearchResult>) {
items = list
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_search_result, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount() = items.size
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val cover: ImageView = itemView.findViewById(R.id.iv_cover)
private val title: TextView = itemView.findViewById(R.id.tv_title)
private val category: TextView = itemView.findViewById(R.id.tv_category)
init {
itemView.setOnClickListener {
val pos = adapterPosition
if (pos != RecyclerView.NO_POSITION) {
onItemClick(items[pos])
}
}
itemView.setOnFocusChangeListener { v, hasFocus ->
if (hasFocus) onItemFocus(v)
}
}
fun bind(item: SearchResult) {
title.text = item.title
category.text = item.category
Glide.with(itemView.context)
.load(item.coverUrl)
.placeholder(android.R.color.darker_gray)
.error(android.R.color.darker_gray)
.centerCrop()
.into(cover)
}
}
}

View File

@@ -0,0 +1,29 @@
package com.videoapp.tv.util
import com.videoapp.tv.data.SiteConfig
import org.jsoup.Jsoup
object JsoupHelper {
fun escapeCssSelector(selector: String): String {
return selector.replace("\"", "\\\"").replace("'", "\\'")
}
suspend fun testSelector(
url: String,
selector: String
): List<String> = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
val doc = Jsoup.connect(url).timeout(10000).get()
doc.select(selector).map { it.text().trim() }
} catch (e: Exception) {
emptyList()
}
}
fun buildSearchPostData(config: SiteConfig, keyword: String): Map<String, String> {
val data = mutableMapOf<String, String>()
data[config.keywordParam] = keyword
data.putAll(config.extraParams)
return data
}
}

View File

@@ -0,0 +1,25 @@
package com.videoapp.tv.util
import android.content.Context
import android.webkit.WebView
object WebViewPool {
private var sharedWebView: WebView? = null
fun get(context: Context): WebView {
return sharedWebView ?: createNew(context).also { sharedWebView = it }
}
private fun createNew(context: Context): WebView {
return WebView(context.applicationContext).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
}
}
fun release() {
sharedWebView?.destroy()
sharedWebView = null
}
}