806 lines
24 KiB
Markdown
806 lines
24 KiB
Markdown
|
|
# 多源策略模式重构 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 为策略模式,每个视频源有独立 Handler,xb6v 用配置驱动,tvcat 重写详情/播放逻辑
|
|||
|
|
|
|||
|
|
**Architecture:** SourceHandler 接口 → BaseSourceHandler 抽象基类(复用 Jsoup 搜索/详情/播放逻辑)→ Xb6vHandler(配置复用)/ TvcatHandler(重写 extractVideos + resolvePlayUrl),SourceRegistry 单例注册管理,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
|