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:
19
app/src/main/java/com/videoapp/tv/MainActivity.kt
Normal file
19
app/src/main/java/com/videoapp/tv/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
226
app/src/main/java/com/videoapp/tv/PlayerActivity.kt
Normal file
226
app/src/main/java/com/videoapp/tv/PlayerActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
100
app/src/main/java/com/videoapp/tv/SettingsActivity.kt
Normal file
100
app/src/main/java/com/videoapp/tv/SettingsActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
28
app/src/main/java/com/videoapp/tv/data/AppDatabase.kt
Normal file
28
app/src/main/java/com/videoapp/tv/data/AppDatabase.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
app/src/main/java/com/videoapp/tv/data/ConfigRepository.kt
Normal file
36
app/src/main/java/com/videoapp/tv/data/ConfigRepository.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
11
app/src/main/java/com/videoapp/tv/data/SearchHistory.kt
Normal file
11
app/src/main/java/com/videoapp/tv/data/SearchHistory.kt
Normal 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()
|
||||
)
|
||||
28
app/src/main/java/com/videoapp/tv/data/SearchHistoryDao.kt
Normal file
28
app/src/main/java/com/videoapp/tv/data/SearchHistoryDao.kt
Normal 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()
|
||||
}
|
||||
9
app/src/main/java/com/videoapp/tv/data/SearchResult.kt
Normal file
9
app/src/main/java/com/videoapp/tv/data/SearchResult.kt
Normal 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
|
||||
)
|
||||
63
app/src/main/java/com/videoapp/tv/data/SiteConfig.kt
Normal file
63
app/src/main/java/com/videoapp/tv/data/SiteConfig.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
84
app/src/main/java/com/videoapp/tv/engine/NativeSearch.kt
Normal file
84
app/src/main/java/com/videoapp/tv/engine/NativeSearch.kt
Normal 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
15
app/src/main/java/com/videoapp/tv/engine/SearchStrategy.kt
Normal file
15
app/src/main/java/com/videoapp/tv/engine/SearchStrategy.kt
Normal 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
|
||||
}
|
||||
113
app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt
Normal file
113
app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/src/main/java/com/videoapp/tv/engine/WebViewDirect.kt
Normal file
24
app/src/main/java/com/videoapp/tv/engine/WebViewDirect.kt
Normal 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
|
||||
}
|
||||
88
app/src/main/java/com/videoapp/tv/engine/WebViewPlayer.kt
Normal file
88
app/src/main/java/com/videoapp/tv/engine/WebViewPlayer.kt
Normal 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
|
||||
}
|
||||
}
|
||||
133
app/src/main/java/com/videoapp/tv/engine/WebViewSearch.kt
Normal file
133
app/src/main/java/com/videoapp/tv/engine/WebViewSearch.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
237
app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt
Normal file
237
app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
66
app/src/main/java/com/videoapp/tv/ui/SearchResultAdapter.kt
Normal file
66
app/src/main/java/com/videoapp/tv/ui/SearchResultAdapter.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
29
app/src/main/java/com/videoapp/tv/util/JsoupHelper.kt
Normal file
29
app/src/main/java/com/videoapp/tv/util/JsoupHelper.kt
Normal 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
|
||||
}
|
||||
}
|
||||
25
app/src/main/java/com/videoapp/tv/util/WebViewPool.kt
Normal file
25
app/src/main/java/com/videoapp/tv/util/WebViewPool.kt
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user