feat: multi-source player with episode selection and auto-hide controls

- VideoExtractor: extract PlaySource (source tabs) with episodes grouped per source
- SiteConfig: add sourceSelector and sourceEpisodeGroupSelector CSS selectors
- PlayerActivity: source tabs + episode list at bottom, auto-hide after 4s, tap to toggle
- SettingsActivity: add source selector configuration fields
- Fullscreen playback with ExoPlayer or WebView fallback
This commit is contained in:
xiaji
2026-05-24 21:30:37 +08:00
parent cf17ce7722
commit cb016c116f
7 changed files with 227 additions and 103 deletions

View File

@@ -1,11 +1,14 @@
package com.videoapp.tv package com.videoapp.tv
import android.os.Bundle import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View import android.view.View
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.Button import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -14,6 +17,7 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.videoapp.tv.data.ConfigRepository import com.videoapp.tv.data.ConfigRepository
import com.videoapp.tv.engine.Episode import com.videoapp.tv.engine.Episode
import com.videoapp.tv.engine.PlaySource
import com.videoapp.tv.engine.VideoExtractor import com.videoapp.tv.engine.VideoExtractor
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -21,8 +25,9 @@ class PlayerActivity : AppCompatActivity() {
private lateinit var playerView: PlayerView private lateinit var playerView: PlayerView
private lateinit var playerWebView: WebView private lateinit var playerWebView: WebView
private lateinit var episodePanel: View private lateinit var controlPanel: View
private lateinit var episodeList: android.widget.LinearLayout private lateinit var sourceList: LinearLayout
private lateinit var episodeList: LinearLayout
private lateinit var loadingIndicator: View private lateinit var loadingIndicator: View
private lateinit var errorText: android.widget.TextView private lateinit var errorText: android.widget.TextView
private lateinit var btnClose: ImageButton private lateinit var btnClose: ImageButton
@@ -31,8 +36,13 @@ class PlayerActivity : AppCompatActivity() {
private val videoExtractor = VideoExtractor() private val videoExtractor = VideoExtractor()
private val configRepo by lazy { ConfigRepository(this) } private val configRepo by lazy { ConfigRepository(this) }
private var episodes: List<Episode> = emptyList() private var sources: List<PlaySource> = emptyList()
private var currentEpisodeIndex = 0 private var currentSourceIndex = 0
private var currentEpisode: Episode? = null
private val hideHandler = Handler(Looper.getMainLooper())
private val hideRunnable = Runnable { hideControls() }
private var controlsVisible = true
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -40,19 +50,19 @@ class PlayerActivity : AppCompatActivity() {
playerView = findViewById(R.id.player_view) playerView = findViewById(R.id.player_view)
playerWebView = findViewById(R.id.player_webview) playerWebView = findViewById(R.id.player_webview)
episodePanel = findViewById(R.id.episode_panel) controlPanel = findViewById(R.id.control_panel)
sourceList = findViewById(R.id.source_list)
episodeList = findViewById(R.id.episode_list) episodeList = findViewById(R.id.episode_list)
loadingIndicator = findViewById(R.id.loading_indicator) loadingIndicator = findViewById(R.id.loading_indicator)
errorText = findViewById(R.id.error_text) errorText = findViewById(R.id.error_text)
btnClose = findViewById(R.id.btn_close) btnClose = findViewById(R.id.btn_close)
val detailUrl = intent.getStringExtra("detail_url") ?: "" val detailUrl = intent.getStringExtra("detail_url") ?: ""
val title = intent.getStringExtra("title") ?: ""
btnClose.setOnClickListener { finish() } btnClose.setOnClickListener { finish() }
initExoPlayer() initExoPlayer()
loadVideos(detailUrl, title) loadSources(detailUrl)
setupTouchListeners() setupTouchListeners()
} }
@@ -62,36 +72,33 @@ class PlayerActivity : AppCompatActivity() {
} }
} }
private fun loadVideos(detailUrl: String, @Suppress("UNUSED_PARAMETER") title: String) { private fun loadSources(detailUrl: String) {
showLoading(true) showLoading(true)
val config = configRepo.getConfig() val config = configRepo.getConfig()
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val videoInfo = videoExtractor.extractVideos(detailUrl, config) sources = videoExtractor.extractVideos(detailUrl, config)
episodes = videoInfo.episodes
if (videoInfo.episodes.isNotEmpty()) { if (sources.isNotEmpty()) {
buildEpisodeUI(videoInfo.episodes) buildSourceUI()
playEpisode(videoInfo.episodes.first()) selectSource(0)
resetAutoHide()
} else { } else {
episodePanel.visibility = View.GONE
tryPlayDirectly(detailUrl, config) tryPlayDirectly(detailUrl, config)
} }
} catch (e: Exception) { } catch (e: Exception) {
showError("加载视频失败: ${e.message}") showError("加载失败: ${e.message}")
showLoading(false) showLoading(false)
} }
} }
} }
private fun buildEpisodeUI(eps: List<Episode>) { private fun buildSourceUI() {
episodeList.removeAllViews() sourceList.removeAllViews()
episodePanel.visibility = View.VISIBLE sources.forEachIndexed { index, source ->
eps.forEachIndexed { index, ep ->
val btn = Button(this).apply { val btn = Button(this).apply {
text = ep.title text = source.name
setBackgroundResource(R.drawable.episode_selector) setBackgroundResource(R.drawable.episode_selector)
setTextColor(ContextCompat.getColor(this@PlayerActivity, R.color.text_primary)) setTextColor(ContextCompat.getColor(this@PlayerActivity, R.color.text_primary))
textSize = 13f textSize = 13f
@@ -100,9 +107,49 @@ class PlayerActivity : AppCompatActivity() {
isFocusable = true isFocusable = true
isFocusableInTouchMode = true isFocusableInTouchMode = true
setOnClickListener { setOnClickListener {
currentEpisodeIndex = index selectSource(index)
resetAutoHide()
}
}
sourceList.addView(btn)
}
}
private fun selectSource(index: Int) {
if (index !in sources.indices) return
currentSourceIndex = index
// Highlight selected source
for (i in 0 until sourceList.childCount) {
sourceList.getChildAt(i).isSelected = (i == index)
}
// Build episode list for this source
val source = sources[index]
buildEpisodeUI(source.episodes)
// Auto-play first episode
if (source.episodes.isNotEmpty()) {
playEpisode(source.episodes.first())
}
}
private fun buildEpisodeUI(eps: List<Episode>) {
episodeList.removeAllViews()
eps.forEach { ep ->
val btn = Button(this).apply {
text = ep.title
setBackgroundResource(R.drawable.episode_selector)
setTextColor(ContextCompat.getColor(this@PlayerActivity, R.color.text_primary))
textSize = 12f
minWidth = 0
setPadding(14, 6, 14, 6)
isFocusable = true
isFocusableInTouchMode = true
setOnClickListener {
highlightEpisode(it) highlightEpisode(it)
playEpisode(ep) playEpisode(ep)
resetAutoHide()
} }
} }
episodeList.addView(btn) episodeList.addView(btn)
@@ -110,6 +157,7 @@ class PlayerActivity : AppCompatActivity() {
} }
private fun playEpisode(ep: Episode) { private fun playEpisode(ep: Episode) {
currentEpisode = ep
showLoading(true) showLoading(true)
val config = configRepo.getConfig() val config = configRepo.getConfig()
@@ -130,10 +178,13 @@ class PlayerActivity : AppCompatActivity() {
lifecycleScope.launch { lifecycleScope.launch {
val (directUrl, iframeUrl) = videoExtractor.extractFromPlayPage(detailUrl, config) val (directUrl, iframeUrl) = videoExtractor.extractFromPlayPage(detailUrl, config)
if (directUrl != null) { if (directUrl != null) {
controlPanel.visibility = View.GONE
playWithExoPlayer(directUrl) playWithExoPlayer(directUrl)
} else if (iframeUrl != null) { } else if (iframeUrl != null) {
controlPanel.visibility = View.GONE
playWithWebView(iframeUrl) playWithWebView(iframeUrl)
} else { } else {
controlPanel.visibility = View.GONE
playWithWebView(detailUrl) playWithWebView(detailUrl)
} }
} }
@@ -186,15 +237,38 @@ class PlayerActivity : AppCompatActivity() {
} }
private fun setupTouchListeners() { private fun setupTouchListeners() {
var panelVisible = true val listener = View.OnClickListener { toggleControls() }
playerView.setOnClickListener { playerView.setOnClickListener(listener)
panelVisible = !panelVisible playerWebView.setOnClickListener(listener)
episodePanel.visibility = if (panelVisible) View.VISIBLE else View.GONE }
private fun toggleControls() {
if (controlsVisible) {
hideControls()
} else {
showControls()
resetAutoHide()
} }
playerWebView.setOnClickListener { }
panelVisible = !panelVisible
episodePanel.visibility = if (panelVisible) View.VISIBLE else View.GONE private fun showControls() {
controlsVisible = true
btnClose.visibility = View.VISIBLE
controlPanel.visibility = View.VISIBLE
}
private fun hideControls() {
controlsVisible = false
btnClose.visibility = View.GONE
controlPanel.visibility = View.GONE
}
private fun resetAutoHide() {
hideHandler.removeCallbacks(hideRunnable)
if (!controlsVisible) {
showControls()
} }
hideHandler.postDelayed(hideRunnable, 4000)
} }
override fun onPause() { override fun onPause() {
@@ -204,6 +278,7 @@ class PlayerActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
hideHandler.removeCallbacks(hideRunnable)
exoPlayer?.release() exoPlayer?.release()
exoPlayer = null exoPlayer = null
} }

View File

@@ -20,6 +20,8 @@ class SettingsActivity : AppCompatActivity() {
private lateinit var editTitleSelector: EditText private lateinit var editTitleSelector: EditText
private lateinit var editCoverSelector: EditText private lateinit var editCoverSelector: EditText
private lateinit var editLinkSelector: EditText private lateinit var editLinkSelector: EditText
private lateinit var editSourceSelector: EditText
private lateinit var editSourceEpisodeGroupSelector: EditText
private lateinit var btnSave: Button private lateinit var btnSave: Button
private lateinit var btnRestore: Button private lateinit var btnRestore: Button
@@ -38,6 +40,8 @@ class SettingsActivity : AppCompatActivity() {
editTitleSelector = findViewById(R.id.edit_title_selector) editTitleSelector = findViewById(R.id.edit_title_selector)
editCoverSelector = findViewById(R.id.edit_cover_selector) editCoverSelector = findViewById(R.id.edit_cover_selector)
editLinkSelector = findViewById(R.id.edit_link_selector) editLinkSelector = findViewById(R.id.edit_link_selector)
editSourceSelector = findViewById(R.id.edit_source_selector)
editSourceEpisodeGroupSelector = findViewById(R.id.edit_source_episode_group_selector)
btnSave = findViewById(R.id.btn_save) btnSave = findViewById(R.id.btn_save)
btnRestore = findViewById(R.id.btn_restore) btnRestore = findViewById(R.id.btn_restore)
@@ -64,6 +68,8 @@ class SettingsActivity : AppCompatActivity() {
editTitleSelector.setText(config.titleSelector) editTitleSelector.setText(config.titleSelector)
editCoverSelector.setText(config.coverSelector) editCoverSelector.setText(config.coverSelector)
editLinkSelector.setText(config.linkSelector) editLinkSelector.setText(config.linkSelector)
editSourceSelector.setText(config.sourceSelector)
editSourceEpisodeGroupSelector.setText(config.sourceEpisodeGroupSelector)
} }
private fun saveConfig() { private fun saveConfig() {
@@ -78,7 +84,9 @@ class SettingsActivity : AppCompatActivity() {
resultSelector = editResultSelector.text.toString().trim(), resultSelector = editResultSelector.text.toString().trim(),
titleSelector = editTitleSelector.text.toString().trim(), titleSelector = editTitleSelector.text.toString().trim(),
coverSelector = editCoverSelector.text.toString().trim(), coverSelector = editCoverSelector.text.toString().trim(),
linkSelector = editLinkSelector.text.toString().trim() linkSelector = editLinkSelector.text.toString().trim(),
sourceSelector = editSourceSelector.text.toString().trim(),
sourceEpisodeGroupSelector = editSourceEpisodeGroupSelector.text.toString().trim()
) )
configRepo.saveConfig(config) configRepo.saveConfig(config)

View File

@@ -45,6 +45,12 @@ data class SiteConfig(
@SerializedName("episode_selector") @SerializedName("episode_selector")
val episodeSelector: String = "a.lBtn", val episodeSelector: String = "a.lBtn",
@SerializedName("source_selector")
val sourceSelector: String = ".playfrom a, .play_source a, .source-list a",
@SerializedName("source_episode_group_selector")
val sourceEpisodeGroupSelector: String = ".playlist > ul, .play_list > ul, .episode-list",
@SerializedName("iframe_selector") @SerializedName("iframe_selector")
val iframeSelector: String = ".video iframe", val iframeSelector: String = ".video iframe",

View File

@@ -5,88 +5,68 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jsoup.Jsoup import org.jsoup.Jsoup
data class VideoInfo(
val detailTitle: String,
val isSeries: Boolean,
val episodes: List<Episode>,
val videoUrl: String?,
val iframeUrl: String?
)
data class Episode( data class Episode(
val title: String, val title: String,
val playUrl: String val playUrl: String
) )
data class PlaySource(
val name: String,
val episodes: List<Episode>
)
class VideoExtractor { class VideoExtractor {
suspend fun extractVideos( suspend fun extractVideos(
detailUrl: String, detailUrl: String,
config: SiteConfig config: SiteConfig
): VideoInfo = withContext(Dispatchers.IO) { ): List<PlaySource> = withContext(Dispatchers.IO) {
val doc = Jsoup.connect(detailUrl).timeout(15000).get() val doc = Jsoup.connect(detailUrl).timeout(15000).get()
val pageTitle = doc.title() // Extract source tabs
val episodes = mutableListOf<Episode>() val sourceTabs = doc.select(config.sourceSelector)
var iframeUrl: String? = null val sourceNames = sourceTabs.map { it.text().trim() }.filter { it.isNotEmpty() }
var directVideoUrl: String? = null
// Extract episodes // Extract episode groups (parallel to source tabs)
val episodeEls = doc.select(config.episodeSelector) val episodeGroups = doc.select(config.sourceEpisodeGroupSelector)
for (ep in episodeEls) {
val sources = mutableListOf<PlaySource>()
if (sourceNames.isNotEmpty() && episodeGroups.size >= sourceNames.size) {
// Pair each source with its episode group
sourceNames.forEachIndexed { i, name ->
val group = episodeGroups[i]
val episodes = extractEpisodesFromGroup(group, config)
if (episodes.isNotEmpty()) {
sources.add(PlaySource(name, episodes))
}
}
}
// Fallback: no sources found, try direct episodes
if (sources.isEmpty()) {
val episodes = extractEpisodesFromGroup(doc, config)
if (episodes.isNotEmpty()) {
sources.add(PlaySource("默认来源", episodes))
}
}
sources
}
private fun extractEpisodesFromGroup(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 title = ep.text().trim()
val href = ep.attr("href")?.trim() ?: "" val href = ep.attr("href").trim()
if (title.isNotEmpty() && href.isNotEmpty()) { if (title.isNotEmpty() && href.isNotEmpty()) {
val fullUrl = if (href.startsWith("http")) href val fullUrl = if (href.startsWith("http")) href
else config.baseUrl.trimEnd('/') + "/" + href.trimStart('/') else config.baseUrl.trimEnd('/') + "/" + href.trimStart('/')
episodes.add(Episode(title, fullUrl)) episodes.add(Episode(title, fullUrl))
} }
} }
return episodes
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( suspend fun extractFromPlayPage(

View File

@@ -19,7 +19,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.videoapp.tv.R import com.videoapp.tv.R
import com.videoapp.tv.BrowserActivity import com.videoapp.tv.PlayerActivity
import com.videoapp.tv.SettingsActivity import com.videoapp.tv.SettingsActivity
import com.videoapp.tv.data.AppDatabase import com.videoapp.tv.data.AppDatabase
import com.videoapp.tv.data.SearchHistory import com.videoapp.tv.data.SearchHistory
@@ -185,9 +185,10 @@ class SearchFragment : Fragment() {
} }
private fun openPlayer(result: SearchResult) { private fun openPlayer(result: SearchResult) {
val intent = Intent(requireContext(), BrowserActivity::class.java).apply { val intent = Intent(requireContext(), PlayerActivity::class.java).apply {
putExtra("url", result.detailUrl) putExtra("detail_url", result.detailUrl)
putExtra("title", result.title) putExtra("title", result.title)
putExtra("category", result.category)
} }
startActivity(intent) startActivity(intent)
} }

View File

@@ -29,18 +29,36 @@
android:scaleType="centerInside" android:scaleType="centerInside"
android:contentDescription="@string/back" /> android:contentDescription="@string/back" />
<FrameLayout <LinearLayout
android:id="@+id/episode_panel" android:id="@+id/control_panel"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="60dp" android:layout_height="wrap_content"
android:layout_gravity="bottom" android:layout_gravity="bottom"
android:orientation="vertical"
android:background="#CC000000" android:background="#CC000000"
android:visibility="gone"> android:visibility="visible">
<HorizontalScrollView
android:id="@+id/source_scroll"
android:layout_width="match_parent"
android:layout_height="44dp"
android:fillViewport="true"
android:scrollbars="none">
<LinearLayout
android:id="@+id/source_list"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="12dp"
android:paddingEnd="12dp" />
</HorizontalScrollView>
<HorizontalScrollView <HorizontalScrollView
android:id="@+id/episode_scroll" android:id="@+id/episode_scroll"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="50dp"
android:fillViewport="true" android:fillViewport="true"
android:scrollbars="none"> android:scrollbars="none">
@@ -50,10 +68,10 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingStart="16dp" android:paddingStart="12dp"
android:paddingEnd="16dp" /> android:paddingEnd="12dp" />
</HorizontalScrollView> </HorizontalScrollView>
</FrameLayout> </LinearLayout>
<ProgressBar <ProgressBar
android:id="@+id/loading_indicator" android:id="@+id/loading_indicator"

View File

@@ -176,6 +176,42 @@
android:padding="16dp" android:padding="16dp"
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textSize="16sp" android:textSize="16sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="播放来源选择器"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<EditText
android:id="@+id/edit_source_selector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/search_bg"
android:inputType="text"
android:padding="16dp"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="剧集分组选择器"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<EditText
android:id="@+id/edit_source_episode_group_selector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/search_bg"
android:inputType="text"
android:padding="16dp"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:layout_marginBottom="24dp" /> android:layout_marginBottom="24dp" />
<LinearLayout <LinearLayout