From 98d05aa90ac3e64e66bab81568396a36596bf774 Mon Sep 17 00:00:00 2001 From: xiaji Date: Sun, 24 May 2026 21:09:05 +0800 Subject: [PATCH] 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 --- .gitignore | 16 ++ app/build.gradle.kts | 82 ++++++ app/proguard-rules.pro | 4 + app/src/main/AndroidManifest.xml | 45 ++++ .../main/java/com/videoapp/tv/MainActivity.kt | 19 ++ .../java/com/videoapp/tv/PlayerActivity.kt | 226 +++++++++++++++++ .../java/com/videoapp/tv/SettingsActivity.kt | 100 ++++++++ .../java/com/videoapp/tv/data/AppDatabase.kt | 28 +++ .../com/videoapp/tv/data/ConfigRepository.kt | 36 +++ .../com/videoapp/tv/data/SearchHistory.kt | 11 + .../com/videoapp/tv/data/SearchHistoryDao.kt | 28 +++ .../java/com/videoapp/tv/data/SearchResult.kt | 9 + .../java/com/videoapp/tv/data/SiteConfig.kt | 63 +++++ .../com/videoapp/tv/engine/NativeSearch.kt | 84 +++++++ .../videoapp/tv/engine/SearchCoordinator.kt | 55 ++++ .../com/videoapp/tv/engine/SearchStrategy.kt | 15 ++ .../com/videoapp/tv/engine/VideoExtractor.kt | 113 +++++++++ .../com/videoapp/tv/engine/WebViewDirect.kt | 24 ++ .../com/videoapp/tv/engine/WebViewPlayer.kt | 88 +++++++ .../com/videoapp/tv/engine/WebViewSearch.kt | 133 ++++++++++ .../java/com/videoapp/tv/ui/SearchFragment.kt | 237 ++++++++++++++++++ .../com/videoapp/tv/ui/SearchResultAdapter.kt | 66 +++++ .../java/com/videoapp/tv/util/JsoupHelper.kt | 29 +++ .../java/com/videoapp/tv/util/WebViewPool.kt | 25 ++ app/src/main/res/drawable/button_bg.xml | 5 + app/src/main/res/drawable/card_bg.xml | 17 ++ .../main/res/drawable/card_focus_border.xml | 6 + app/src/main/res/drawable/episode_bg.xml | 5 + .../main/res/drawable/episode_selector.xml | 6 + app/src/main/res/drawable/history_chip_bg.xml | 5 + .../res/drawable/history_chip_selector.xml | 5 + .../res/drawable/ic_launcher_background.xml | 10 + .../res/drawable/ic_launcher_foreground.xml | 10 + app/src/main/res/drawable/search_bg.xml | 6 + .../main/res/drawable/search_bg_focused.xml | 6 + .../main/res/drawable/search_bg_selector.xml | 5 + app/src/main/res/drawable/tv_banner.xml | 16 ++ app/src/main/res/layout/activity_main.xml | 12 + app/src/main/res/layout/activity_player.xml | 63 +++++ app/src/main/res/layout/activity_settings.xml | 210 ++++++++++++++++ app/src/main/res/layout/fragment_search.xml | 128 ++++++++++ .../main/res/layout/item_search_result.xml | 39 +++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.xml | 13 + app/src/main/res/mipmap-mdpi/ic_launcher.xml | 13 + app/src/main/res/mipmap-xhdpi/ic_launcher.xml | 13 + .../main/res/mipmap-xxhdpi/ic_launcher.xml | 13 + app/src/main/res/values/colors.xml | 18 ++ app/src/main/res/values/strings.xml | 27 ++ app/src/main/res/values/themes.xml | 52 ++++ build.gradle.kts | 5 + gradle.properties | 4 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43462 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradle_exit_console | 1 + gradlew.bat | 89 +++++++ settings.gradle.kts | 18 ++ 57 files changed, 2366 insertions(+) create mode 100644 .gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/videoapp/tv/MainActivity.kt create mode 100644 app/src/main/java/com/videoapp/tv/PlayerActivity.kt create mode 100644 app/src/main/java/com/videoapp/tv/SettingsActivity.kt create mode 100644 app/src/main/java/com/videoapp/tv/data/AppDatabase.kt create mode 100644 app/src/main/java/com/videoapp/tv/data/ConfigRepository.kt create mode 100644 app/src/main/java/com/videoapp/tv/data/SearchHistory.kt create mode 100644 app/src/main/java/com/videoapp/tv/data/SearchHistoryDao.kt create mode 100644 app/src/main/java/com/videoapp/tv/data/SearchResult.kt create mode 100644 app/src/main/java/com/videoapp/tv/data/SiteConfig.kt create mode 100644 app/src/main/java/com/videoapp/tv/engine/NativeSearch.kt create mode 100644 app/src/main/java/com/videoapp/tv/engine/SearchCoordinator.kt create mode 100644 app/src/main/java/com/videoapp/tv/engine/SearchStrategy.kt create mode 100644 app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt create mode 100644 app/src/main/java/com/videoapp/tv/engine/WebViewDirect.kt create mode 100644 app/src/main/java/com/videoapp/tv/engine/WebViewPlayer.kt create mode 100644 app/src/main/java/com/videoapp/tv/engine/WebViewSearch.kt create mode 100644 app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt create mode 100644 app/src/main/java/com/videoapp/tv/ui/SearchResultAdapter.kt create mode 100644 app/src/main/java/com/videoapp/tv/util/JsoupHelper.kt create mode 100644 app/src/main/java/com/videoapp/tv/util/WebViewPool.kt create mode 100644 app/src/main/res/drawable/button_bg.xml create mode 100644 app/src/main/res/drawable/card_bg.xml create mode 100644 app/src/main/res/drawable/card_focus_border.xml create mode 100644 app/src/main/res/drawable/episode_bg.xml create mode 100644 app/src/main/res/drawable/episode_selector.xml create mode 100644 app/src/main/res/drawable/history_chip_bg.xml create mode 100644 app/src/main/res/drawable/history_chip_selector.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/search_bg.xml create mode 100644 app/src/main/res/drawable/search_bg_focused.xml create mode 100644 app/src/main/res/drawable/search_bg_selector.xml create mode 100644 app/src/main/res/drawable/tv_banner.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/activity_player.xml create mode 100644 app/src/main/res/layout/activity_settings.xml create mode 100644 app/src/main/res/layout/fragment_search.xml create mode 100644 app/src/main/res/layout/item_search_result.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradle_exit_console create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9262b1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/build +/app/release +*.apk +*.aab +*.jks +*.keystore diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..5dc8259 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,82 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.google.devtools.ksp") +} + +android { + namespace = "com.videoapp.tv" + compileSdk = 34 + + defaultConfig { + applicationId = "com.videoapp.tv" + minSdk = 21 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + } +} + +dependencies { + // Leanback (TV UI) + implementation("androidx.leanback:leanback:1.0.0") + implementation("androidx.leanback:leanback-preference:1.0.0") + + // AndroidX + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("androidx.fragment:fragment-ktx:1.6.2") + implementation("androidx.constraintlayout:constraintlayout:2.1.4") + implementation("androidx.recyclerview:recyclerview:1.3.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + implementation("androidx.activity:activity-ktx:1.8.1") + + // ExoPlayer (Media3) + implementation("androidx.media3:media3-exoplayer:1.2.0") + implementation("androidx.media3:media3-ui:1.2.0") + implementation("androidx.media3:media3-exoplayer-hls:1.2.0") + + // Jsoup (HTML parsing) + implementation("org.jsoup:jsoup:1.17.2") + + // Room (database) + implementation("androidx.room:room-runtime:2.6.1") + implementation("androidx.room:room-ktx:2.6.1") + ksp("androidx.room:room-compiler:2.6.1") + + // Gson (JSON) + implementation("com.google.code.gson:gson:2.10.1") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // Glide (image loading) + implementation("com.github.bumptech.glide:glide:4.16.0") + + // WebView + implementation("androidx.webkit:webkit:1.8.0") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f2cbf12 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,4 @@ +# Add project specific ProGuard rules here. +-keepattributes *Annotation* +-keep class com.videoapp.tv.data.** { *; } +-dontwarn org.jsoup.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7483ee5 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/videoapp/tv/MainActivity.kt b/app/src/main/java/com/videoapp/tv/MainActivity.kt new file mode 100644 index 0000000..655ec1d --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/MainActivity.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/videoapp/tv/PlayerActivity.kt b/app/src/main/java/com/videoapp/tv/PlayerActivity.kt new file mode 100644 index 0000000..3a21bd9 --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/PlayerActivity.kt @@ -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 = 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) { + 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 + } +} diff --git a/app/src/main/java/com/videoapp/tv/SettingsActivity.kt b/app/src/main/java/com/videoapp/tv/SettingsActivity.kt new file mode 100644 index 0000000..15b6ba3 --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/SettingsActivity.kt @@ -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 { + val map = mutableMapOf() + 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 + } +} diff --git a/app/src/main/java/com/videoapp/tv/data/AppDatabase.kt b/app/src/main/java/com/videoapp/tv/data/AppDatabase.kt new file mode 100644 index 0000000..bbe4d8a --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/data/AppDatabase.kt @@ -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 + } + } + } +} diff --git a/app/src/main/java/com/videoapp/tv/data/ConfigRepository.kt b/app/src/main/java/com/videoapp/tv/data/ConfigRepository.kt new file mode 100644 index 0000000..00606cc --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/data/ConfigRepository.kt @@ -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" + } +} diff --git a/app/src/main/java/com/videoapp/tv/data/SearchHistory.kt b/app/src/main/java/com/videoapp/tv/data/SearchHistory.kt new file mode 100644 index 0000000..0e26ef0 --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/data/SearchHistory.kt @@ -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() +) diff --git a/app/src/main/java/com/videoapp/tv/data/SearchHistoryDao.kt b/app/src/main/java/com/videoapp/tv/data/SearchHistoryDao.kt new file mode 100644 index 0000000..a895815 --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/data/SearchHistoryDao.kt @@ -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> + + @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() +} diff --git a/app/src/main/java/com/videoapp/tv/data/SearchResult.kt b/app/src/main/java/com/videoapp/tv/data/SearchResult.kt new file mode 100644 index 0000000..9462ddd --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/data/SearchResult.kt @@ -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 +) diff --git a/app/src/main/java/com/videoapp/tv/data/SiteConfig.kt b/app/src/main/java/com/videoapp/tv/data/SiteConfig.kt new file mode 100644 index 0000000..ed1b543 --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/data/SiteConfig.kt @@ -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 = 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() + } +} diff --git a/app/src/main/java/com/videoapp/tv/engine/NativeSearch.kt b/app/src/main/java/com/videoapp/tv/engine/NativeSearch.kt new file mode 100644 index 0000000..f69631f --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/engine/NativeSearch.kt @@ -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) -> 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() + + 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}") + } + } +} diff --git a/app/src/main/java/com/videoapp/tv/engine/SearchCoordinator.kt b/app/src/main/java/com/videoapp/tv/engine/SearchCoordinator.kt new file mode 100644 index 0000000..cc98f3e --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/engine/SearchCoordinator.kt @@ -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) -> 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() +} diff --git a/app/src/main/java/com/videoapp/tv/engine/SearchStrategy.kt b/app/src/main/java/com/videoapp/tv/engine/SearchStrategy.kt new file mode 100644 index 0000000..fd485ec --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/engine/SearchStrategy.kt @@ -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) -> Unit, + onError: suspend (String) -> Unit + ) + + fun getName(): String +} diff --git a/app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt b/app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt new file mode 100644 index 0000000..72f4787 --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt @@ -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, + 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() + 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 = 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) + } + } +} diff --git a/app/src/main/java/com/videoapp/tv/engine/WebViewDirect.kt b/app/src/main/java/com/videoapp/tv/engine/WebViewDirect.kt new file mode 100644 index 0000000..730b04a --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/engine/WebViewDirect.kt @@ -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) -> Unit, + onError: suspend (String) -> Unit + ) { + onError("FALLBACK_WEBVIEW") + } + + fun getWebView(): WebView? = webView +} diff --git a/app/src/main/java/com/videoapp/tv/engine/WebViewPlayer.kt b/app/src/main/java/com/videoapp/tv/engine/WebViewPlayer.kt new file mode 100644 index 0000000..f4063db --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/engine/WebViewPlayer.kt @@ -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 + } +} diff --git a/app/src/main/java/com/videoapp/tv/engine/WebViewSearch.kt b/app/src/main/java/com/videoapp/tv/engine/WebViewSearch.kt new file mode 100644 index 0000000..5a777db --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/engine/WebViewSearch.kt @@ -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) -> Unit, + onError: suspend (String) -> Unit + ) { + withContext(Dispatchers.Main) { + val result = withTimeoutOrNull(20000L) { + suspendCancellableCoroutine> { 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 { + 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() + } + } +} diff --git a/app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt b/app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt new file mode 100644 index 0000000..1d9c4fb --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt @@ -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) { + 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() + } +} diff --git a/app/src/main/java/com/videoapp/tv/ui/SearchResultAdapter.kt b/app/src/main/java/com/videoapp/tv/ui/SearchResultAdapter.kt new file mode 100644 index 0000000..0db7d26 --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/ui/SearchResultAdapter.kt @@ -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() { + + private var items: List = emptyList() + + fun setItems(list: List) { + 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) + } + } +} diff --git a/app/src/main/java/com/videoapp/tv/util/JsoupHelper.kt b/app/src/main/java/com/videoapp/tv/util/JsoupHelper.kt new file mode 100644 index 0000000..ca8d179 --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/util/JsoupHelper.kt @@ -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 = 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 { + val data = mutableMapOf() + data[config.keywordParam] = keyword + data.putAll(config.extraParams) + return data + } +} diff --git a/app/src/main/java/com/videoapp/tv/util/WebViewPool.kt b/app/src/main/java/com/videoapp/tv/util/WebViewPool.kt new file mode 100644 index 0000000..c7b1998 --- /dev/null +++ b/app/src/main/java/com/videoapp/tv/util/WebViewPool.kt @@ -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 + } +} diff --git a/app/src/main/res/drawable/button_bg.xml b/app/src/main/res/drawable/button_bg.xml new file mode 100644 index 0000000..c8e5df7 --- /dev/null +++ b/app/src/main/res/drawable/button_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/card_bg.xml b/app/src/main/res/drawable/card_bg.xml new file mode 100644 index 0000000..8b496b4 --- /dev/null +++ b/app/src/main/res/drawable/card_bg.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/card_focus_border.xml b/app/src/main/res/drawable/card_focus_border.xml new file mode 100644 index 0000000..52d8bc3 --- /dev/null +++ b/app/src/main/res/drawable/card_focus_border.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/episode_bg.xml b/app/src/main/res/drawable/episode_bg.xml new file mode 100644 index 0000000..be77d69 --- /dev/null +++ b/app/src/main/res/drawable/episode_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/episode_selector.xml b/app/src/main/res/drawable/episode_selector.xml new file mode 100644 index 0000000..92b0327 --- /dev/null +++ b/app/src/main/res/drawable/episode_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/history_chip_bg.xml b/app/src/main/res/drawable/history_chip_bg.xml new file mode 100644 index 0000000..bb5c115 --- /dev/null +++ b/app/src/main/res/drawable/history_chip_bg.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/history_chip_selector.xml b/app/src/main/res/drawable/history_chip_selector.xml new file mode 100644 index 0000000..66e8905 --- /dev/null +++ b/app/src/main/res/drawable/history_chip_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..a856c5b --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..73acede --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/search_bg.xml b/app/src/main/res/drawable/search_bg.xml new file mode 100644 index 0000000..63a84d8 --- /dev/null +++ b/app/src/main/res/drawable/search_bg.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/search_bg_focused.xml b/app/src/main/res/drawable/search_bg_focused.xml new file mode 100644 index 0000000..4fb571f --- /dev/null +++ b/app/src/main/res/drawable/search_bg_focused.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/search_bg_selector.xml b/app/src/main/res/drawable/search_bg_selector.xml new file mode 100644 index 0000000..8be86db --- /dev/null +++ b/app/src/main/res/drawable/search_bg_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/tv_banner.xml b/app/src/main/res/drawable/tv_banner.xml new file mode 100644 index 0000000..359b7f0 --- /dev/null +++ b/app/src/main/res/drawable/tv_banner.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..c0833b5 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml new file mode 100644 index 0000000..eef8086 --- /dev/null +++ b/app/src/main/res/layout/activity_player.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..6c61bd8 --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +