Compare commits

19 Commits
v1.1 ... main

Author SHA1 Message Date
xiaji
5ca5c659b8 refactor: 移除tvcat源配置,保留xb6v和lequgo 2026-06-10 20:12:02 +08:00
xiaji
e2118d8faa feat: 添加lequgo(片吧影院)源,支持搜索/详情/播放 2026-06-10 19:35:40 +08:00
xiaji
100ecb4aef fix: TvcatHandler搜索和详情修复UTF-8编码,使用Jsoup.parseBodyFragment替代parse 2026-06-09 21:37:31 +08:00
xiaji
15e13d9e37 fix: TvcatHandler搜索和详情用HttpURLConnection替代Jsoup下载,绕过Jsoup默认UA被反爬拦截 2026-06-09 20:39:33 +08:00
xiaji
667bd9bbe8 fix: Jsoup请求添加User-Agent头,防止tvcat反爬拦截 2026-06-09 20:04:04 +08:00
xiaji
60e2e8ee77 fix: tvcat搜索extraParams默认为空,避免xb6v参数污染GET请求 2026-06-09 20:01:52 +08:00
xiaji
16b1cbfc31 feat: SettingsActivity 使用 SourceRegistry 驱动源切换 2026-06-09 19:55:36 +08:00
xiaji
2824f3f396 feat: SearchFragment 使用 SourceHandler 执行搜索 2026-06-09 19:55:27 +08:00
xiaji
42cb79609a feat: PlayerActivity 使用 SourceHandler 获取剧集和播放链接 2026-06-09 19:54:43 +08:00
xiaji
3a3f401ddc feat: 应用启动时初始化 SourceRegistry 2026-06-09 19:54:27 +08:00
xiaji
f79f0c7a16 feat: ConfigRepository 添加 currentSourceId 存储 2026-06-09 19:53:34 +08:00
xiaji
b199b12a83 feat: 添加 SourceRegistry 注册中心 2026-06-09 19:53:05 +08:00
xiaji
dea2700da1 feat: 添加 TvcatHandler,支持 /_fetch_p/ API 获取播放链接 2026-06-09 19:52:29 +08:00
xiaji
a75225332b feat: 添加 Xb6vHandler 2026-06-09 19:52:15 +08:00
xiaji
08ab90bb47 feat: 添加 BaseSourceHandler 抽象基类 2026-06-09 19:51:38 +08:00
xiaji
507e3fdbb4 feat: 添加 SourceHandler 接口 2026-06-09 19:50:22 +08:00
xiaji
146fc9b8c4 docs: 多源策略模式实现计划 2026-06-09 19:44:15 +08:00
xiaji
4f00412962 docs: 多源策略模式重构设计文档 2026-06-09 18:49:27 +08:00
xiaji
b82cc74aad fix: 修复tvcat.cc选择器和播放页URL提取
- 详情页: sourceSelector/episodeGroupSelector置空,强制走fallback正确提取剧集
- 播放页: 新增fetchPlayUrlFromApi方法,通过/_fetch_p/ API获取m3u8直链
- PlayerActivity: extractFromPlayPage返回空时尝试API获取,失败再用WebView
2026-06-08 22:21:00 +08:00
16 changed files with 1604 additions and 47 deletions

View File

@@ -2,12 +2,14 @@ package com.videoapp.tv
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.videoapp.tv.engine.SourceRegistry
import com.videoapp.tv.ui.SearchFragment
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
SourceRegistry.init(this)
setContentView(R.layout.activity_main)
if (savedInstanceState == null) {

View File

@@ -26,7 +26,7 @@ import com.videoapp.tv.data.ConfigRepository
import com.videoapp.tv.data.PlayHistory
import com.videoapp.tv.engine.Episode
import com.videoapp.tv.engine.PlaySource
import com.videoapp.tv.engine.VideoExtractor
import com.videoapp.tv.engine.SourceRegistry
import kotlinx.coroutines.launch
class PlayerActivity : AppCompatActivity() {
@@ -43,7 +43,6 @@ class PlayerActivity : AppCompatActivity() {
private lateinit var brightnessVolumeIndicator: TextView
private var exoPlayer: ExoPlayer? = null
private val videoExtractor = VideoExtractor()
private val configRepo by lazy { ConfigRepository(this) }
private val playHistoryDao by lazy { AppDatabase.getInstance(this).playHistoryDao() }
private val audioManager by lazy { getSystemService(Context.AUDIO_SERVICE) as AudioManager }
@@ -140,18 +139,19 @@ class PlayerActivity : AppCompatActivity() {
private fun loadSources(detailUrl: String) {
showLoading(true)
val config = configRepo.getConfig()
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
lifecycleScope.launch {
try {
sources = videoExtractor.extractVideos(detailUrl, config)
sources = handler?.extractVideos(detailUrl) ?: emptyList()
if (sources.isNotEmpty()) {
buildSourceUI()
selectSource(0)
resetAutoHide()
} else {
tryPlayDirectly(detailUrl, config)
tryPlayDirectly(detailUrl)
}
} catch (e: Exception) {
showError("加载失败: ${e.message}")
@@ -247,12 +247,14 @@ class PlayerActivity : AppCompatActivity() {
private fun playEpisode(ep: Episode) {
currentEpisode = ep
showLoading(true)
val config = configRepo.getConfig()
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
savePlayHistory(ep.title)
lifecycleScope.launch {
val (directUrl, iframeUrl) = videoExtractor.extractFromPlayPage(ep.playUrl, config)
val (directUrl, iframeUrl) = handler?.resolvePlayUrl(ep.playUrl)
?: Pair(null, null)
if (directUrl != null) {
playWithExoPlayer(directUrl)
@@ -314,11 +316,14 @@ class PlayerActivity : AppCompatActivity() {
positionSaveHandler.removeCallbacks(positionSaveRunnable)
}
private fun tryPlayDirectly(detailUrl: String, config: com.videoapp.tv.data.SiteConfig) {
private fun tryPlayDirectly(detailUrl: String) {
savePlayHistory(videoTitle)
lifecycleScope.launch {
val (directUrl, iframeUrl) = videoExtractor.extractFromPlayPage(detailUrl, config)
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
val (directUrl, iframeUrl) = handler?.resolvePlayUrl(detailUrl)
?: Pair(null, null)
if (directUrl != null) {
controlPanel.visibility = View.GONE
playWithExoPlayer(directUrl)

View File

@@ -11,7 +11,8 @@ import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.videoapp.tv.data.ConfigRepository
import com.videoapp.tv.data.SiteConfig
import com.videoapp.tv.data.SitePreset
import com.videoapp.tv.engine.SourceHandler
import com.videoapp.tv.engine.SourceRegistry
class SettingsActivity : AppCompatActivity() {
@@ -53,44 +54,40 @@ class SettingsActivity : AppCompatActivity() {
btnSave = findViewById(R.id.btn_save)
btnRestore = findViewById(R.id.btn_restore)
setupPresetSpinner()
setupSourceSpinner()
loadConfig()
btnSave.setOnClickListener { saveConfig() }
btnRestore.setOnClickListener {
configRepo.restoreDefault()
ignoringSpinner = true
spinnerPreset.setSelection(0)
ignoringSpinner = false
loadConfig()
Toast.makeText(this, R.string.config_restored, Toast.LENGTH_SHORT).show()
}
}
private fun setupPresetSpinner() {
val presets = configRepo.getPresets()
val presetNames = presets.map { it.name }
private fun setupSourceSpinner() {
val handlers = SourceRegistry.getAll()
val names = handlers.map { it.displayName }
val adapter = ArrayAdapter(
this,
android.R.layout.simple_spinner_item,
presetNames
names
)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinnerPreset.adapter = adapter
val currentId = configRepo.getCurrentSourceId()
val currentIndex = handlers.indexOfFirst { it.id == currentId }
if (currentIndex >= 0) {
spinnerPreset.setSelection(currentIndex)
}
spinnerPreset.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
if (ignoringSpinner) return
val preset = presets.getOrNull(position) ?: return
configRepo.applyPreset(preset)
loadConfigFromPreset(preset.config)
Toast.makeText(
this@SettingsActivity,
"已切换到 ${preset.name}",
Toast.LENGTH_SHORT
).show()
val handler = handlers[position]
configRepo.setCurrentSourceId(handler.id)
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
}
@@ -115,10 +112,9 @@ class SettingsActivity : AppCompatActivity() {
val config = configRepo.getConfig()
loadConfigFromPreset(config)
val presets = configRepo.getPresets()
val matchedIndex = presets.indexOfFirst {
it.config.baseUrl == config.baseUrl
}
val handlers = SourceRegistry.getAll()
val currentId = configRepo.getCurrentSourceId()
val matchedIndex = handlers.indexOfFirst { it.id == currentId }
if (matchedIndex >= 0) {
ignoringSpinner = true
spinnerPreset.setSelection(matchedIndex)

View File

@@ -30,6 +30,14 @@ class ConfigRepository(context: Context) {
saveConfig(SiteConfig.default())
}
fun getCurrentSourceId(): String {
return prefs.getString("current_source_id", "xb6v") ?: "xb6v"
}
fun setCurrentSourceId(id: String) {
prefs.edit().putString("current_source_id", id).apply()
}
fun getPresets(): List<SitePreset> = SitePreset.PRESETS
fun applyPreset(preset: SitePreset) {

View File

@@ -47,8 +47,8 @@ data class SitePreset(
categorySelector = ".text-muted",
dateSelector = "",
episodeSelector = "li.list-inline-item a",
sourceSelector = "h2",
sourceEpisodeGroupSelector = "ul.list-unstyled",
sourceSelector = "",
sourceEpisodeGroupSelector = "",
iframeSelector = "iframe",
videoSelector = "video source, video[src]"
)

View File

@@ -0,0 +1,165 @@
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
abstract class BaseSourceHandler(
override val id: String,
override val displayName: String,
override val baseUrl: String,
protected val config: SiteConfig
) : SourceHandler {
override suspend fun search(
keyword: String,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
) {
try {
val results = withContext(Dispatchers.IO) {
val url = "${baseUrl.trimEnd('/')}/${config.searchPath.trimStart('/')}"
val connection: Connection = Jsoup.connect(url)
.data(config.keywordParam, keyword)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.header("Accept", "text/html,application/xhtml+xml")
.header("Accept-Language", "zh-CN,zh;q=0.9")
.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 = buildFullUrl(linkEl?.attr("href")?.trim() ?: "")
val coverUrl = buildFullUrl(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 = coverUrl,
detailUrl = detailUrl,
category = category,
date = date
)
)
}
} catch (_: Exception) {
}
}
resultsList
}
if (results.isEmpty()) {
onError("未找到结果")
} else {
onResult(results)
}
} catch (e: Exception) {
onError("搜索失败: ${e.message}")
}
}
override suspend fun extractVideos(detailUrl: String): List<PlaySource> =
withContext(Dispatchers.IO) {
val doc = Jsoup.connect(detailUrl)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.header("Accept-Language", "zh-CN,zh;q=0.9")
.timeout(15000).get()
val sourceTabs = doc.select(config.sourceSelector)
val sourceNames = sourceTabs.map { it.text().trim() }.filter { it.isNotEmpty() }
val episodeGroups = doc.select(config.sourceEpisodeGroupSelector)
val sources = mutableListOf<PlaySource>()
if (sourceNames.isNotEmpty() && episodeGroups.size >= sourceNames.size) {
sourceNames.forEachIndexed { i, name ->
val group = episodeGroups[i]
val episodes = extractEpisodes(group, config)
if (episodes.isNotEmpty()) {
sources.add(PlaySource(name, episodes))
}
}
}
if (sources.isEmpty()) {
val episodes = extractEpisodes(doc, config)
if (episodes.isNotEmpty()) {
sources.add(PlaySource("默认来源", episodes))
}
}
sources
}
override suspend fun resolvePlayUrl(playUrl: String): Pair<String?, String?> =
withContext(Dispatchers.IO) {
try {
val doc = Jsoup.connect(playUrl)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.header("Accept-Language", "zh-CN,zh;q=0.9")
.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 (_: Exception) {
Pair(null, null)
}
}
private fun extractEpisodes(
container: org.jsoup.nodes.Element,
config: SiteConfig
): List<Episode> {
val episodes = mutableListOf<Episode>()
val eps = container.select(config.episodeSelector)
for (ep in eps) {
val title = ep.text().trim()
val href = ep.attr("href").trim()
if (title.isNotEmpty() && href.isNotEmpty()) {
episodes.add(Episode(title, buildFullUrl(href)))
}
}
return episodes
}
protected fun buildFullUrl(href: String): String {
if (href.isEmpty()) return ""
if (href.startsWith("http")) return href
return "${baseUrl.trimEnd('/')}/${href.trimStart('/')}"
}
}

View File

@@ -0,0 +1,19 @@
package com.videoapp.tv.engine
import com.videoapp.tv.data.SearchResult
interface SourceHandler {
val id: String
val displayName: String
val baseUrl: String
suspend fun search(
keyword: String,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
)
suspend fun extractVideos(detailUrl: String): List<PlaySource>
suspend fun resolvePlayUrl(playUrl: String): Pair<String?, String?>
}

View File

@@ -0,0 +1,56 @@
package com.videoapp.tv.engine
import android.content.Context
import com.videoapp.tv.data.ConfigRepository
import com.videoapp.tv.data.SiteConfig
import com.videoapp.tv.engine.lequgo.LequgoHandler
import com.videoapp.tv.engine.xb6v.Xb6vHandler
object SourceRegistry {
private val handlers = mutableMapOf<String, SourceHandler>()
fun register(handler: SourceHandler) {
handlers[handler.id] = handler
}
fun get(id: String): SourceHandler = handlers[id]
?: throw IllegalArgumentException("Unknown source: $id")
fun getOrDefault(id: String): SourceHandler? = handlers[id]
fun getAll(): List<SourceHandler> = handlers.values.toList()
fun init(context: Context) {
val configRepo = ConfigRepository(context)
val savedConfig = configRepo.getConfig()
val xb6vConfig = xb6vConfig()
register(Xb6vHandler(xb6vConfig))
register(LequgoHandler())
}
private fun xb6vConfig() = SiteConfig(
baseUrl = "https://www.xb6v.com",
searchPath = "/e/search/11index.php",
searchMethod = "POST",
keywordParam = "keyboard",
extraParams = mapOf(
"show" to "title",
"tempid" to "1",
"tbname" to "article",
"mid" to "1",
"dopost" to "search"
),
resultSelector = "li.post",
titleSelector = "h2 a",
coverSelector = ".thumbnail img",
linkSelector = ".thumbnail a",
categorySelector = ".info_category a",
dateSelector = ".info_date",
episodeSelector = "a.lBtn",
sourceSelector = ".playfrom a, .play_source a, .source-list a",
sourceEpisodeGroupSelector = ".playlist > ul, .play_list > ul, .episode-list",
iframeSelector = ".video iframe",
videoSelector = "video source, video[src]"
)
}

View File

@@ -3,7 +3,9 @@ package com.videoapp.tv.engine
import com.videoapp.tv.data.SiteConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.jsoup.Jsoup
import java.net.URL
data class Episode(
val title: String,
@@ -90,4 +92,30 @@ class VideoExtractor {
Pair(null, null)
}
}
suspend fun fetchPlayUrlFromApi(
playUrl: String,
baseUrl: String
): String? = withContext(Dispatchers.IO) {
try {
val pattern = Regex("""(\d+)/ep(\d+)""")
val match = pattern.find(playUrl) ?: return@withContext null
val videoId = match.groupValues[1]
val epNum = match.groupValues[2]
val apiUrl = "${baseUrl.trimEnd('/')}/_fetch_p/$videoId/ep$epNum"
val conn = URL(apiUrl).openConnection()
conn.setRequestProperty("User-Agent", "Mozilla/5.0")
conn.setRequestProperty("Referer", playUrl)
conn.connectTimeout = 15000
conn.readTimeout = 15000
val json = conn.getInputStream().bufferedReader().use { it.readText() }
val obj = JSONObject(json)
val playcfgs = obj.getJSONArray("playcfgs")
if (playcfgs.length() > 0) {
playcfgs.getJSONObject(0).getString("url")
} else null
} catch (e: Exception) {
null
}
}
}

View File

@@ -0,0 +1,159 @@
package com.videoapp.tv.engine.lequgo
import com.videoapp.tv.data.SearchResult
import com.videoapp.tv.data.SiteConfig
import com.videoapp.tv.engine.BaseSourceHandler
import com.videoapp.tv.engine.Episode
import com.videoapp.tv.engine.PlaySource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
class LequgoHandler : BaseSourceHandler(
id = "lequgo",
displayName = "lequgo (片吧影院)",
baseUrl = "https://www.lequgo.com",
config = SiteConfig(
baseUrl = "https://www.lequgo.com",
searchPath = "/vodsearch/",
searchMethod = "GET",
keywordParam = "wd",
extraParams = emptyMap(),
resultSelector = "ul#searchList > li",
titleSelector = "h4.title a",
coverSelector = "a.myui-vodlist__thumb",
linkSelector = "h4.title a",
categorySelector = ".text-muted:contains(类型)",
dateSelector = "",
episodeSelector = "ul.myui-content__list li a",
sourceSelector = "",
sourceEpisodeGroupSelector = "",
iframeSelector = "iframe",
videoSelector = "video source, video[src]"
)
) {
override suspend fun search(
keyword: String,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
) {
try {
val results = withContext(Dispatchers.IO) {
val encoded = URLEncoder.encode(keyword, "UTF-8")
val searchUrl = "https://www.lequgo.com/vodsearch/${encoded}-------------.html"
val conn = URL(searchUrl).openConnection() as HttpURLConnection
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
conn.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
conn.connectTimeout = 15000
conn.readTimeout = 15000
val html = if (conn.responseCode == 200) {
conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
} else ""
conn.disconnect()
if (html.isEmpty()) return@withContext emptyList<SearchResult>()
val doc = Jsoup.parseBodyFragment(html)
val items = doc.select("ul#searchList > li")
val resultsList = mutableListOf<SearchResult>()
for (item in items) {
try {
val titleEl = item.selectFirst("h4.title a") ?: continue
val linkEl = item.selectFirst("h4.title a")
val coverEl = item.selectFirst("a.myui-vodlist__thumb")
val categoryEl = item.selectFirst("span.text-muted:contains(类型)")
val title = titleEl.text().trim()
val detailUrl = buildFullUrl(linkEl?.attr("href")?.trim() ?: "")
val coverUrl = coverEl?.attr("data-original")?.trim() ?: ""
val category = categoryEl?.text()?.replace("类型:", "")?.trim() ?: ""
if (title.isNotEmpty() && detailUrl.isNotEmpty()) {
resultsList.add(
SearchResult(
title = title,
coverUrl = coverUrl,
detailUrl = detailUrl,
category = category,
date = ""
)
)
}
} catch (_: Exception) {
}
}
resultsList
}
if (results.isEmpty()) {
onError("未找到结果")
} else {
onResult(results)
}
} catch (e: Exception) {
onError("搜索失败: ${e.message}")
}
}
override suspend fun extractVideos(detailUrl: String): List<PlaySource> =
withContext(Dispatchers.IO) {
val conn = URL(detailUrl).openConnection() as HttpURLConnection
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
conn.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
conn.connectTimeout = 15000
conn.readTimeout = 15000
val html = if (conn.responseCode == 200) {
conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
} else ""
conn.disconnect()
if (html.isEmpty()) return@withContext emptyList()
val doc = Jsoup.parseBodyFragment(html)
val episodes = doc.select("ul.myui-content__list li a").mapNotNull { ep ->
val title = ep.text().trim()
val href = ep.attr("href").trim()
if (title.isNotEmpty() && href.isNotEmpty()) {
Episode(title, buildFullUrl(href))
} else null
}
if (episodes.isNotEmpty()) {
listOf(PlaySource("默认来源", episodes))
} else emptyList()
}
override suspend fun resolvePlayUrl(playUrl: String): Pair<String?, String?> =
withContext(Dispatchers.IO) {
try {
val conn = URL(playUrl).openConnection() as HttpURLConnection
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
conn.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
conn.connectTimeout = 15000
conn.readTimeout = 15000
val html = if (conn.responseCode == 200) {
conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
} else ""
conn.disconnect()
if (html.isEmpty()) return@withContext Pair(null, null)
val match = Regex("""var\s+player_aaaa\s*=\s*(\{[^}]+\})""").find(html)
if (match != null) {
val jsonStr = match.groupValues[1]
val urlMatch = Regex(""""url"\s*:\s*"([^"]+)"""").find(jsonStr)
if (urlMatch != null) {
val videoUrl = urlMatch.groupValues[1]
.replace("\\/", "/")
.replace("\\u002F", "/")
return@withContext Pair(videoUrl, null)
}
}
Pair(null, null)
} catch (_: Exception) {
Pair(null, null)
}
}
}

View File

@@ -0,0 +1,155 @@
package com.videoapp.tv.engine.tvcat
import com.videoapp.tv.data.SearchResult
import com.videoapp.tv.data.SiteConfig
import com.videoapp.tv.engine.BaseSourceHandler
import com.videoapp.tv.engine.Episode
import com.videoapp.tv.engine.PlaySource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.jsoup.Jsoup
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
class TvcatHandler : BaseSourceHandler(
id = "tvcat",
displayName = "tvcat (电视猫)",
baseUrl = "https://tvcat.cc",
config = SiteConfig(
baseUrl = "https://tvcat.cc",
searchPath = "/search",
searchMethod = "GET",
keywordParam = "q",
extraParams = emptyMap(),
resultSelector = "li.col-md-2.col-sm-3.col-4",
titleSelector = "a[title]",
coverSelector = "img",
linkSelector = "a",
categorySelector = ".text-muted",
dateSelector = "",
episodeSelector = "li.list-inline-item a",
sourceSelector = "",
sourceEpisodeGroupSelector = "",
iframeSelector = "iframe",
videoSelector = "video source, video[src]"
)
) {
override suspend fun search(
keyword: String,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
) {
try {
val results = withContext(Dispatchers.IO) {
val encoded = URLEncoder.encode(keyword, "UTF-8")
val searchUrl = "https://tvcat.cc/search?q=$encoded"
val conn = URL(searchUrl).openConnection() as HttpURLConnection
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
conn.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
conn.setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
conn.connectTimeout = 15000
conn.readTimeout = 15000
val html = if (conn.responseCode == 200) {
conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
} else ""
conn.disconnect()
if (html.isEmpty()) return@withContext emptyList<SearchResult>()
val doc = Jsoup.parseBodyFragment(html)
val items = doc.select("li.col-md-2.col-sm-3.col-4")
val resultsList = mutableListOf<SearchResult>()
for (item in items) {
try {
val titleEl = item.selectFirst("a[title]") ?: continue
val linkEl = item.selectFirst("a")
val coverEl = item.selectFirst("img")
val categoryEl = item.selectFirst(".text-muted")
val title = titleEl.text().trim()
val detailUrl = buildFullUrl(linkEl?.attr("href")?.trim() ?: "")
val coverUrl = buildFullUrl(coverEl?.attr("src")?.trim() ?: "")
val category = categoryEl?.text()?.trim() ?: ""
if (title.isNotEmpty() && detailUrl.isNotEmpty()) {
resultsList.add(
SearchResult(
title = title,
coverUrl = coverUrl,
detailUrl = detailUrl,
category = category,
date = ""
)
)
}
} catch (_: Exception) {
}
}
resultsList
}
if (results.isEmpty()) {
onError("未找到结果")
} else {
onResult(results)
}
} catch (e: Exception) {
onError("搜索失败: ${e.message}")
}
}
override suspend fun extractVideos(detailUrl: String): List<PlaySource> =
withContext(Dispatchers.IO) {
val conn = URL(detailUrl).openConnection() as HttpURLConnection
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
conn.setRequestProperty("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
conn.connectTimeout = 15000
conn.readTimeout = 15000
val html = if (conn.responseCode == 200) {
conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
} else ""
conn.disconnect()
if (html.isEmpty()) return@withContext emptyList()
val doc = Jsoup.parseBodyFragment(html)
val episodes = doc.select("li.list-inline-item a").mapNotNull { ep ->
val title = ep.text().trim()
val href = ep.attr("href").trim()
if (title.isNotEmpty() && href.isNotEmpty()) {
Episode(title, buildFullUrl(href))
} else null
}
if (episodes.isNotEmpty()) {
listOf(PlaySource("默认来源", episodes))
} else emptyList()
}
override suspend fun resolvePlayUrl(playUrl: String): Pair<String?, String?> =
withContext(Dispatchers.IO) {
try {
val match = Regex("""(\d+)/ep(\d+)""").find(playUrl)
if (match != null) {
val videoId = match.groupValues[1]
val epNum = match.groupValues[2]
val apiUrl = "https://tvcat.cc/_fetch_p/$videoId/ep$epNum"
val conn = URL(apiUrl).openConnection()
conn.setRequestProperty("User-Agent", "Mozilla/5.0")
conn.setRequestProperty("Referer", playUrl)
conn.connectTimeout = 15000
conn.readTimeout = 15000
val json = conn.getInputStream().bufferedReader().use { it.readText() }
val obj = JSONObject(json)
val playcfgs = obj.getJSONArray("playcfgs")
if (playcfgs.length() > 0) {
val url = playcfgs.getJSONObject(0).getString("url")
Pair(url, null)
} else Pair(null, null)
} else Pair(null, null)
} catch (_: Exception) {
Pair(null, null)
}
}
}

View File

@@ -0,0 +1,11 @@
package com.videoapp.tv.engine.xb6v
import com.videoapp.tv.data.SiteConfig
import com.videoapp.tv.engine.BaseSourceHandler
class Xb6vHandler(config: SiteConfig) : BaseSourceHandler(
id = "xb6v",
displayName = "xb6v (星辰影视)",
baseUrl = "https://www.xb6v.com",
config = config
)

View File

@@ -25,7 +25,8 @@ import com.videoapp.tv.data.AppDatabase
import com.videoapp.tv.data.PlayHistory
import com.videoapp.tv.data.SearchHistory
import com.videoapp.tv.data.SearchResult
import com.videoapp.tv.engine.SearchCoordinator
import com.videoapp.tv.data.ConfigRepository
import com.videoapp.tv.engine.SourceRegistry
import kotlinx.coroutines.launch
class SearchFragment : Fragment() {
@@ -45,7 +46,7 @@ class SearchFragment : Fragment() {
private lateinit var statusText: TextView
private lateinit var fallbackWebView: WebView
private val searchCoordinator by lazy { SearchCoordinator(requireContext()) }
private val configRepo by lazy { ConfigRepository(requireContext()) }
private val historyDao by lazy { AppDatabase.getInstance(requireContext()).searchHistoryDao() }
private val playHistoryDao by lazy { AppDatabase.getInstance(requireContext()).playHistoryDao() }
private val adapter by lazy {
@@ -137,6 +138,13 @@ class SearchFragment : Fragment() {
private fun performSearch(keyword: String) {
if (keyword.isEmpty()) return
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
if (handler == null) {
showStatus("未知源")
return
}
hideFallbackWebView()
showLoading(true)
statusText.visibility = View.GONE
@@ -144,7 +152,6 @@ class SearchFragment : Fragment() {
playHistoryContainer.visibility = View.GONE
lifecycleScope.launch {
// Save to history
val existing = historyDao.findByKeyword(keyword)
if (existing != null) {
historyDao.updateTime(existing.id, System.currentTimeMillis())
@@ -154,7 +161,7 @@ class SearchFragment : Fragment() {
}
lifecycleScope.launch {
searchCoordinator.search(
handler.search(
keyword = keyword,
onResult = { results ->
showLoading(false)
@@ -165,22 +172,15 @@ class SearchFragment : Fragment() {
},
onError = { error ->
showLoading(false)
if (error == "FALLBACK_WEBVIEW") {
showFallbackWebView(keyword)
} else {
showStatus("搜索失败,请重试")
Toast.makeText(requireContext(), error, Toast.LENGTH_LONG).show()
}
},
onFallbackToWebView = {
showFallbackWebView(keyword)
showStatus("搜索失败,请重试")
Toast.makeText(requireContext(), error, Toast.LENGTH_LONG).show()
}
)
}
}
private fun showFallbackWebView(keyword: String) {
val config = searchCoordinator.getConfig()
val config = configRepo.getConfig()
val base = config.baseUrl.trimEnd('/')
val path = config.searchPath.trimStart('/')
val params = "${config.keywordParam}=${java.net.URLEncoder.encode(keyword, "UTF-8")}"

View File

@@ -0,0 +1,805 @@
# 多源策略模式重构 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 重构 VideoExtractor/NativeSearch 为策略模式,每个视频源有独立 Handlerxb6v 用配置驱动tvcat 重写详情/播放逻辑
**Architecture:** SourceHandler 接口 → BaseSourceHandler 抽象基类(复用 Jsoup 搜索/详情/播放逻辑)→ Xb6vHandler配置复用/ TvcatHandler重写 extractVideos + resolvePlayUrlSourceRegistry 单例注册管理SettingsActivity/PlayerActivity/SearchFragment 通过 Registry 获取当前 Handler
**Tech Stack:** Kotlin, Jsoup, org.json, ExoPlayer, Coroutines
---
### Task 1: 创建 SourceHandler 接口
**Files:**
- Create: `app/src/main/java/com/videoapp/tv/engine/SourceHandler.kt`
- [ ] **Step 1: 写入接口文件**
```kotlin
package com.videoapp.tv.engine
import com.videoapp.tv.data.SearchResult
interface SourceHandler {
val id: String
val displayName: String
val baseUrl: String
suspend fun search(
keyword: String,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
)
suspend fun extractVideos(detailUrl: String): List<PlaySource>
suspend fun resolvePlayUrl(playUrl: String): Pair<String?, String?>
}
```
- [ ] **Step 2: 验证编译**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 3: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/engine/SourceHandler.kt
git commit -m "feat: 添加 SourceHandler 接口"
```
---
### Task 2: 创建 BaseSourceHandler 抽象基类
**Files:**
- Create: `app/src/main/java/com/videoapp/tv/engine/BaseSourceHandler.kt`
- [ ] **Step 1: 写入基类,包含通用搜索、详情页解析、播放页解析**
BaseSourceHandler 需引用 `SiteConfig``SearchResult``PlaySource``Episode`
```kotlin
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
abstract class BaseSourceHandler(
override val id: String,
override val displayName: String,
override val baseUrl: String,
protected val config: SiteConfig
) : SourceHandler {
override suspend fun search(
keyword: String,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
) {
try {
val results = withContext(Dispatchers.IO) {
val url = "${baseUrl.trimEnd('/')}/${config.searchPath.trimStart('/')}"
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 = buildFullUrl(linkEl?.attr("href")?.trim() ?: "")
val coverUrl = buildFullUrl(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 = coverUrl,
detailUrl = detailUrl,
category = category,
date = date
)
)
}
} catch (_: Exception) {
}
}
resultsList
}
if (results.isEmpty()) {
onError("未找到结果")
} else {
onResult(results)
}
} catch (e: Exception) {
onError("搜索失败: ${e.message}")
}
}
override suspend fun extractVideos(detailUrl: String): List<PlaySource> =
withContext(Dispatchers.IO) {
val doc = Jsoup.connect(detailUrl).timeout(15000).get()
val sourceTabs = doc.select(config.sourceSelector)
val sourceNames = sourceTabs.map { it.text().trim() }.filter { it.isNotEmpty() }
val episodeGroups = doc.select(config.sourceEpisodeGroupSelector)
val sources = mutableListOf<PlaySource>()
if (sourceNames.isNotEmpty() && episodeGroups.size >= sourceNames.size) {
sourceNames.forEachIndexed { i, name ->
val group = episodeGroups[i]
val episodes = extractEpisodes(group, config)
if (episodes.isNotEmpty()) {
sources.add(PlaySource(name, episodes))
}
}
}
if (sources.isEmpty()) {
val episodes = extractEpisodes(doc, config)
if (episodes.isNotEmpty()) {
sources.add(PlaySource("默认来源", episodes))
}
}
sources
}
override suspend fun resolvePlayUrl(playUrl: String): 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 (_: Exception) {
Pair(null, null)
}
}
private fun extractEpisodes(
container: org.jsoup.nodes.Element,
config: SiteConfig
): List<Episode> {
val episodes = mutableListOf<Episode>()
val eps = container.select(config.episodeSelector)
for (ep in eps) {
val title = ep.text().trim()
val href = ep.attr("href").trim()
if (title.isNotEmpty() && href.isNotEmpty()) {
episodes.add(Episode(title, buildFullUrl(href)))
}
}
return episodes
}
protected fun buildFullUrl(href: String): String {
if (href.isEmpty()) return ""
if (href.startsWith("http")) return href
return "${baseUrl.trimEnd('/')}/${href.trimStart('/')}"
}
}
```
- [ ] **Step 2: 更新 import 和编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
期望通过sourceSelector 和 sourceEpisodeGroupSelector 允许为空串)
- [ ] **Step 3: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/engine/BaseSourceHandler.kt
git commit -m "feat: 添加 BaseSourceHandler 抽象基类"
```
---
### Task 3: 创建 Xb6vHandler
**Files:**
- Create: `app/src/main/java/com/videoapp/tv/engine/xb6v/Xb6vHandler.kt`
- [ ] **Step 1: 写入 Xb6vHandler完全复用基类**
```kotlin
package com.videoapp.tv.engine.xb6v
import com.videoapp.tv.data.SiteConfig
import com.videoapp.tv.engine.BaseSourceHandler
class Xb6vHandler(config: SiteConfig) : BaseSourceHandler(
id = "xb6v",
displayName = "xb6v (星辰影视)",
baseUrl = "https://www.xb6v.com",
config = config
)
```
- [ ] **Step 2: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 3: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/engine/xb6v/Xb6vHandler.kt
git commit -m "feat: 添加 Xb6vHandler"
```
---
### Task 4: 创建 TvcatHandler重写 extractVideos 和 resolvePlayUrl
**Files:**
- Create: `app/src/main/java/com/videoapp/tv/engine/tvcat/TvcatHandler.kt`
- [ ] **Step 1: 写入 TvcatHandler**
TvcatHandler 继承 BaseSourceHandler使用内置的 tvcat 专用 SiteConfig 配置,重写 extractVideos详情页只有一个源直接用 li.list-inline-item a 提取剧集)和 resolvePlayUrl调用 /_fetch_p/ API 获取 m3u8
```kotlin
package com.videoapp.tv.engine.tvcat
import com.videoapp.tv.data.SiteConfig
import com.videoapp.tv.engine.BaseSourceHandler
import com.videoapp.tv.engine.Episode
import com.videoapp.tv.engine.PlaySource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.jsoup.Jsoup
import java.net.URL
class TvcatHandler : BaseSourceHandler(
id = "tvcat",
displayName = "tvcat (电视猫)",
baseUrl = "https://tvcat.cc",
config = SiteConfig(
baseUrl = "https://tvcat.cc",
searchPath = "/search",
searchMethod = "GET",
keywordParam = "q",
resultSelector = "li.col-md-2.col-sm-3.col-4",
titleSelector = "a[title]",
coverSelector = "img",
linkSelector = "a",
categorySelector = ".text-muted",
dateSelector = "",
episodeSelector = "li.list-inline-item a",
sourceSelector = "",
sourceEpisodeGroupSelector = "",
iframeSelector = "iframe",
videoSelector = "video source, video[src]"
)
) {
override suspend fun extractVideos(detailUrl: String): List<PlaySource> =
withContext(Dispatchers.IO) {
val doc = Jsoup.connect(detailUrl).timeout(15000).get()
val episodes = doc.select("li.list-inline-item a").mapNotNull { ep ->
val title = ep.text().trim()
val href = ep.attr("href").trim()
if (title.isNotEmpty() && href.isNotEmpty()) {
Episode(title, buildFullUrl(href))
} else null
}
if (episodes.isNotEmpty()) {
listOf(PlaySource("默认来源", episodes))
} else emptyList()
}
override suspend fun resolvePlayUrl(playUrl: String): Pair<String?, String?> =
withContext(Dispatchers.IO) {
try {
val match = Regex("""(\d+)/ep(\d+)""").find(playUrl)
if (match != null) {
val videoId = match.groupValues[1]
val epNum = match.groupValues[2]
val apiUrl = "https://tvcat.cc/_fetch_p/$videoId/ep$epNum"
val conn = URL(apiUrl).openConnection()
conn.setRequestProperty("User-Agent", "Mozilla/5.0")
conn.setRequestProperty("Referer", playUrl)
conn.connectTimeout = 15000
conn.readTimeout = 15000
val json = conn.getInputStream().bufferedReader().use { it.readText() }
val obj = JSONObject(json)
val playcfgs = obj.getJSONArray("playcfgs")
if (playcfgs.length() > 0) {
val url = playcfgs.getJSONObject(0).getString("url")
Pair(url, null)
} else Pair(null, null)
} else Pair(null, null)
} catch (_: Exception) {
Pair(null, null)
}
}
}
```
- [ ] **Step 2: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 3: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/engine/tvcat/TvcatHandler.kt
git commit -m "feat: 添加 TvcatHandler支持 /_fetch_p/ API 获取播放链接"
```
---
### Task 5: 创建 SourceRegistry 注册中心
**Files:**
- Create: `app/src/main/java/com/videoapp/tv/engine/SourceRegistry.kt`
- [ ] **Step 1: 写入注册中心**
```kotlin
package com.videoapp.tv.engine
import android.content.Context
import com.videoapp.tv.data.ConfigRepository
import com.videoapp.tv.data.SiteConfig
import com.videoapp.tv.engine.tvcat.TvcatHandler
import com.videoapp.tv.engine.xb6v.Xb6vHandler
object SourceRegistry {
private val handlers = mutableMapOf<String, SourceHandler>()
fun register(handler: SourceHandler) {
handlers[handler.id] = handler
}
fun get(id: String): SourceHandler = handlers[id]
?: throw IllegalArgumentException("Unknown source: $id")
fun getOrDefault(id: String): SourceHandler? = handlers[id]
fun getAll(): List<SourceHandler> = handlers.values.toList()
fun init(context: Context) {
val configRepo = ConfigRepository(context)
val savedConfig = configRepo.getConfig()
val xb6vConfig = xb6vConfig()
register(Xb6vHandler(xb6vConfig))
register(TvcatHandler())
}
private fun xb6vConfig() = SiteConfig(
baseUrl = "https://www.xb6v.com",
searchPath = "/e/search/11index.php",
searchMethod = "POST",
keywordParam = "keyboard",
extraParams = mapOf(
"show" to "title",
"tempid" to "1",
"tbname" to "article",
"mid" to "1",
"dopost" to "search"
),
resultSelector = "li.post",
titleSelector = "h2 a",
coverSelector = ".thumbnail img",
linkSelector = ".thumbnail a",
categorySelector = ".info_category a",
dateSelector = ".info_date",
episodeSelector = "a.lBtn",
sourceSelector = ".playfrom a, .play_source a, .source-list a",
sourceEpisodeGroupSelector = ".playlist > ul, .play_list > ul, .episode-list",
iframeSelector = ".video iframe",
videoSelector = "video source, video[src]"
)
}
```
- [ ] **Step 2: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 3: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/engine/SourceRegistry.kt
git commit -m "feat: 添加 SourceRegistry 注册中心"
```
---
### Task 6: 修改 ConfigRepository添加 currentSourceId 存储
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/data/ConfigRepository.kt`
- [ ] **Step 1: 读取 ConfigRepository**
```powershell
# 需要先读取文件确认当前代码
```
- [ ] **Step 2: 添加 currentSourceId 读写方法**
在 ConfigRepository 中添加 `getCurrentSourceId()``setCurrentSourceId(id: String)`,使用 SharedPreferences 的 key `current_source_id`。同时废弃旧的 `getPresets()` / `applyPreset()` 方法(或保留兼容)。
```kotlin
fun getCurrentSourceId(): String {
return prefs.getString("current_source_id", "xb6v") ?: "xb6v"
}
fun setCurrentSourceId(id: String) {
prefs.edit().putString("current_source_id", id).apply()
}
```
- [ ] **Step 3: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 4: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/data/ConfigRepository.kt
git commit -m "feat: ConfigRepository 添加 currentSourceId 存储"
```
---
### Task 7: 修改 SettingsActivity使用 Registry 驱动 Spinner
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/SettingsActivity.kt:60-80`
- Modify: `app/src/main/res/layout/activity_settings.xml`
- [ ] **Step 1: 读取当前 SettingsActivity 和布局文件**
需要先读取确认当前 Spinner 相关的代码。
- [ ] **Step 2: 修改 Spinner 数据源**
将 Spinner 数据源从 `SitePreset.PRESETS` 改为 `SourceRegistry.getAll()`
```kotlin
private fun setupSourceSpinner() {
val handlers = SourceRegistry.getAll()
val names = handlers.map { it.displayName }
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, names)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
spinnerSource.adapter = adapter
val currentId = configRepo.getCurrentSourceId()
val currentIndex = handlers.indexOfFirst { it.id == currentId }
if (currentIndex >= 0) {
spinnerSource.setSelection(currentIndex)
}
spinnerSource.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) {
if (ignoringSpinner) return
val handler = handlers[position]
configRepo.setCurrentSourceId(handler.id)
}
override fun onNothingSelected(parent: AdapterView<*>) {}
}
}
```
- [ ] **Step 3: 移除旧的 SitePreset 引用**
删除 `import com.videoapp.tv.data.SitePreset`,删除 `applyPreset` 调用。
- [ ] **Step 4: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 5: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/SettingsActivity.kt app/src/main/res/layout/activity_settings.xml
git commit -m "feat: SettingsActivity 使用 SourceRegistry 驱动源切换"
```
---
### Task 8: 修改 PlayerActivity通过 Handler 获取数据
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/PlayerActivity.kt:141-161` (loadSources)
- Modify: `app/src/main/java/com/videoapp/tv/PlayerActivity.kt:247-269` (playEpisode)
- [ ] **Step 1: 读取 PlayerActivity 当前代码**
需要确认精确行号和当前代码结构。
- [ ] **Step 2: 修改 loadSources 和 playEpisode**
`videoExtractor.extractVideos()` 改为 `handler.extractVideos()`,将 `videoExtractor.extractFromPlayPage()` + `fetchPlayUrlFromApi()` 改为 `handler.resolvePlayUrl()`
```kotlin
// loadSources
private fun loadSources(detailUrl: String) {
showLoading(true)
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
lifecycleScope.launch {
try {
sources = handler?.extractVideos(detailUrl) ?: emptyList()
if (sources.isNotEmpty()) {
buildSourceUI()
selectSource(0)
resetAutoHide()
} else {
tryPlayDirectly(detailUrl)
}
} catch (e: Exception) {
showError("加载失败: ${e.message}")
showLoading(false)
}
}
}
// playEpisode
private fun playEpisode(ep: Episode) {
currentEpisode = ep
showLoading(true)
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
savePlayHistory(ep.title)
lifecycleScope.launch {
val (directUrl, iframeUrl) = handler?.resolvePlayUrl(ep.playUrl)
?: Pair(null, null)
if (directUrl != null) {
playWithExoPlayer(directUrl)
} else if (iframeUrl != null) {
playWithWebView(iframeUrl)
} else {
playWithWebView(ep.playUrl)
}
}
}
```
同时修改 `tryPlayDirectly` 方法签名,移除 `config` 参数:
```kotlin
private fun tryPlayDirectly(detailUrl: String) {
savePlayHistory(videoTitle)
lifecycleScope.launch {
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
val (directUrl, iframeUrl) = handler?.resolvePlayUrl(detailUrl)
?: Pair(null, null)
if (directUrl != null) {
controlPanel.visibility = View.GONE
playWithExoPlayer(directUrl)
} else if (iframeUrl != null) {
controlPanel.visibility = View.GONE
playWithWebView(iframeUrl)
} else {
controlPanel.visibility = View.GONE
playWithWebView(detailUrl)
}
}
}
```
- [ ] **Step 3: 移除旧的 VideoExtractor 引用**
删除 `private val videoExtractor = VideoExtractor()` 字段。
- [ ] **Step 4: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 5: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/PlayerActivity.kt
git commit -m "feat: PlayerActivity 使用 SourceHandler 获取剧集和播放链接"
```
---
### Task 9: 修改 SearchFragment通过 Handler 执行搜索
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt`
- [ ] **Step 1: 读取 SearchFragment 当前代码**
- [ ] **Step 2: 修改搜索调用**
`NativeSearch().search()` 改为 `SourceRegistry.get(currentId).search()`
```kotlin
private fun performSearch(query: String) {
val currentId = configRepo.getCurrentSourceId()
val handler = SourceRegistry.getOrDefault(currentId)
if (handler == null) {
showError("未知源")
return
}
settingsIcon?.visibility = View.VISIBLE
lifecycleScope.launch {
handler.search(
keyword = query,
onResult = { results ->
adapter.setItems(results)
},
onError = { error ->
showError(error)
}
)
}
}
```
- [ ] **Step 3: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 4: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt
git commit -m "feat: SearchFragment 使用 SourceHandler 执行搜索"
```
---
### Task 10: 在 Application 或 MainActivity 中初始化 Registry
**Files:**
- Modify: `app/src/main/java/com/videoapp/tv/MainActivity.kt`(或 Application 类,取决于现有架构)
- [ ] **Step 1: 找到合适的初始化入口**
查找是否存在 Application 类,否则在 MainActivity.onCreate 中初始化。
- [ ] **Step 2: 添加初始化代码**
```kotlin
// 在 MainActivity.onCreate 中
SourceRegistry.init(this)
```
- [ ] **Step 3: 编译验证**
```powershell
.\gradlew.bat compileDebugKotlin 2>&1
```
- [ ] **Step 4: 提交**
```powershell
git add app/src/main/java/com/videoapp/tv/MainActivity.kt
git commit -m "feat: 应用启动时初始化 SourceRegistry"
```
---
### Task 11: 清理废弃代码
**Files:**
- 保留: `app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt`(保留,其他旧引用可能还在用)
- 保留: `app/src/main/java/com/videoapp/tv/engine/NativeSearch.kt`(保留兼容)
- 标记: `app/src/main/java/com/videoapp/tv/data/SitePreset.kt`(已不再使用,可选删除或保留)
- [ ] **Step 1: 检查是否还有代码引用 VideoExtractor / NativeSearch / SitePreset**
```powershell
# 搜索引用
```
如果只有新代码引用 Handler可以选择删除这些旧文件或保留作为参考。
- [ ] **Step 2: 删除或注释过时的 import**
确保不在报编译错误。
- [ ] **Step 3: 编译并构建 APK**
```powershell
.\gradlew.bat assembleDebug 2>&1
```
期望BUILD SUCCESSFUL
- [ ] **Step 4: 提交**
```powershell
git add .
git commit -m "refactor: 清理废弃代码,移除旧的 VideoExtractor/NativeSearch 引用"
```
---
### Task 12: 推送并部署 APK
- [ ] **Step 1: 推送到远程仓库**
```powershell
git push origin main 2>&1
```
- [ ] **Step 2: 上传 APK 到 Gitea Release**
```powershell
# 删除旧 asset
curl.exe -s -X DELETE "http://124.223.26.33:3000/api/v1/repos/xiaji/android-tv/releases/2/assets/3" -u "xiaji:xiaji1234"
# 上传新 APK
curl.exe -s -X POST "http://124.223.26.33:3000/api/v1/repos/xiaji/android-tv/releases/2/assets" -u "xiaji:xiaji1234" -F "attachment=@app\build\outputs\apk\debug\app-debug.apk" -F "name=app-debug.apk"
```
- [ ] **Step 3: 验证下载**
```powershell
curl.exe -s -I "http://124.223.26.33:3000/xiaji/android-tv/releases/download/v1.1/app-debug.apk"
```
期望HTTP/1.1 200 OK

View File

@@ -0,0 +1,148 @@
# 多源策略模式重构设计文档
## 1. 背景与问题
当前架构使用单一 `VideoExtractor` + `SiteConfig` 选择器处理所有视频源,但 xb6v (星辰影视) 和 tvcat (电视猫) 差异巨大:
| 维度 | xb6v | tvcat |
|------|------|-------|
| 搜索 | POST 表单 + 额外参数 | GET query 参数 |
| 详情页 | 多源标签页 + 剧集列表 | 单源"播放"标题,多源在播放页 JS 加载 |
| 播放页 | iframe/video 标签直接可用 | iframe 空,需调用 `/_fetch_p/` API 获取 m3u8 |
强行用选择器配置导致代码耦合、难以维护、新增源需修改核心逻辑。
## 2. 设计目标
1. **策略模式**:每个源独立实现 `SourceHandler` 接口
2. **基类复用**:通用 Jsoup 逻辑在 `BaseSourceHandler`,配置型源零代码
3. **特殊隔离**tvcat 的 API 调用封装在 `TvcatHandler` 内部
4. **统一 UI**:搜索结果、详情页、播放页 UI 完全共用
5. **易扩展**:新增源 = 新建 Handler + 注册,不改核心代码
## 3. 架构设计
### 3.1 核心接口
```kotlin
// engine/SourceHandler.kt
interface SourceHandler {
val id: String
val displayName: String
val baseUrl: String
suspend fun search(
keyword: String,
onResult: suspend (List<SearchResult>) -> Unit,
onError: suspend (String) -> Unit
)
suspend fun extractVideos(detailUrl: String): List<PlaySource>
suspend fun resolvePlayUrl(playUrl: String): Pair<String?, String?>
}
```
### 3.2 基类实现
```kotlin
// engine/BaseSourceHandler.kt
abstract class BaseSourceHandler(
override val id: String,
override val displayName: String,
override val baseUrl: String,
protected val config: SiteConfig
) : SourceHandler {
// 通用搜索POST/GET、参数注入、结果解析
override suspend fun search(...) { ... }
// 通用详情页sourceSelector + sourceEpisodeGroupSelector 配对fallback 到 episodeSelector
override suspend fun extractVideos(detailUrl): List<PlaySource> { ... }
// 通用播放页iframeSelector / videoSelector
override suspend fun resolvePlayUrl(playUrl): Pair<String?, String?> { ... }
protected fun buildFullUrl(href: String): String { ... }
}
```
### 3.3 具体 Handler
**Xb6vHandler** - 完全复用基类,仅需正确的 `SiteConfig` 选择器配置
**TvcatHandler** - 重写两个关键方法:
- `extractVideos()`:详情页只有一个源"播放",直接用 `li.list-inline-item a` 提取所有剧集
- `resolvePlayUrl()`:解析 `/vod-play/{id}/ep{num}`,调用 `${baseUrl}/_fetch_p/{id}/ep{num}` API返回第一个 m3u8 URL
### 3.4 注册中心
```kotlin
// engine/SourceRegistry.kt
object SourceRegistry {
private val handlers = mutableMapOf<String, SourceHandler>()
fun register(handler: SourceHandler) { handlers[handler.id] = handler }
fun get(id: String): SourceHandler? = handlers[id]
fun getAll(): List<SourceHandler> = handlers.values.toList()
fun init(context: Context) {
val configRepo = ConfigRepository(context)
register(Xb6vHandler(configRepo.getXb6vConfig()))
register(TvcatHandler())
}
}
```
### 3.5 集成点
**SettingsActivity** - Spinner 显示 `SourceRegistry.getAll().map { it.displayName }`,选择时保存 `currentSourceId`
**PlayerActivity** - 通过 `SourceRegistry.get(config.currentSourceId)` 获取 Handler调用 `extractVideos()``resolvePlayUrl()`
**SearchFragment** - 同理通过 Registry 获取当前源的 Handler 执行搜索
## 4. 数据流
| 场景 | 调用链 |
|------|--------|
| 搜索 | SearchFragment → SourceRegistry.get(id).search() → BaseSourceHandler.search() |
| 详情页解析 | PlayerActivity.loadSources() → handler.extractVideos() → 基类或子类重写 |
| 播放链接解析 | PlayerActivity.playEpisode() → handler.resolvePlayUrl() → 基类或子类重写 |
## 5. 文件结构
```
engine/
├── SourceHandler.kt (新建,接口)
├── BaseSourceHandler.kt (新建,抽象基类)
├── SourceRegistry.kt (新建,注册中心)
├── VideoExtractor.kt (废弃,保留兼容)
├── NativeSearch.kt (废弃,保留兼容)
├── xb6v/
│ └── Xb6vHandler.kt (新建)
└── tvcat/
└── TvcatHandler.kt (新建,含 API 调用)
```
## 6. 错误处理
- 网络异常:基类统一 `try-catch`,返回空列表/空 Pair上层显示错误
- 解析失败:选择器匹配不到 → fallback 逻辑(基类已有)
- 源切换Settings 保存 `currentSourceId`App 启动时 `SourceRegistry.init()` 恢复
## 7. 优势
1. **扩展性**:新增源 = 新建 `XxxHandler` + 注册,不改动核心代码
2. **复用**xb6v 类源完全靠配置,零代码
3. **隔离**tvcat 特殊逻辑API 调用)封装在自己 Handler 里
4. **测试**:每个 Handler 可独立单元测试
5. **统一 UI**:搜索结果、详情页、播放页 UI 全共用,仅数据源不同
## 8. 风险与缓解
| 风险 | 缓解 |
|------|------|
| 基类过度膨胀 | 只放真正通用的逻辑,特殊情况让子类重写 |
| 选择器配置出错 | 添加配置校验,启动时检查必填字段 |
| 并发安全 | Handler 无状态多线程安全Registry 用单例模式 |

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB