diff --git a/app/src/main/java/com/videoapp/tv/PlayerActivity.kt b/app/src/main/java/com/videoapp/tv/PlayerActivity.kt index 06ddb90..21df949 100644 --- a/app/src/main/java/com/videoapp/tv/PlayerActivity.kt +++ b/app/src/main/java/com/videoapp/tv/PlayerActivity.kt @@ -1,11 +1,14 @@ package com.videoapp.tv import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.View import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Button import android.widget.ImageButton +import android.widget.LinearLayout import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.lifecycle.lifecycleScope @@ -14,6 +17,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import com.videoapp.tv.data.ConfigRepository import com.videoapp.tv.engine.Episode +import com.videoapp.tv.engine.PlaySource import com.videoapp.tv.engine.VideoExtractor import kotlinx.coroutines.launch @@ -21,8 +25,9 @@ class PlayerActivity : AppCompatActivity() { private lateinit var playerView: PlayerView private lateinit var playerWebView: WebView - private lateinit var episodePanel: View - private lateinit var episodeList: android.widget.LinearLayout + private lateinit var controlPanel: View + private lateinit var sourceList: LinearLayout + private lateinit var episodeList: LinearLayout private lateinit var loadingIndicator: View private lateinit var errorText: android.widget.TextView private lateinit var btnClose: ImageButton @@ -31,8 +36,13 @@ class PlayerActivity : AppCompatActivity() { private val videoExtractor = VideoExtractor() private val configRepo by lazy { ConfigRepository(this) } - private var episodes: List = emptyList() - private var currentEpisodeIndex = 0 + private var sources: List = emptyList() + 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?) { super.onCreate(savedInstanceState) @@ -40,19 +50,19 @@ class PlayerActivity : AppCompatActivity() { playerView = findViewById(R.id.player_view) 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) loadingIndicator = findViewById(R.id.loading_indicator) errorText = findViewById(R.id.error_text) btnClose = findViewById(R.id.btn_close) val detailUrl = intent.getStringExtra("detail_url") ?: "" - val title = intent.getStringExtra("title") ?: "" btnClose.setOnClickListener { finish() } initExoPlayer() - loadVideos(detailUrl, title) + loadSources(detailUrl) 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) val config = configRepo.getConfig() lifecycleScope.launch { try { - val videoInfo = videoExtractor.extractVideos(detailUrl, config) - episodes = videoInfo.episodes + sources = videoExtractor.extractVideos(detailUrl, config) - if (videoInfo.episodes.isNotEmpty()) { - buildEpisodeUI(videoInfo.episodes) - playEpisode(videoInfo.episodes.first()) + if (sources.isNotEmpty()) { + buildSourceUI() + selectSource(0) + resetAutoHide() } else { - episodePanel.visibility = View.GONE tryPlayDirectly(detailUrl, config) } } catch (e: Exception) { - showError("加载视频失败: ${e.message}") + showError("加载失败: ${e.message}") showLoading(false) } } } - private fun buildEpisodeUI(eps: List) { - episodeList.removeAllViews() - episodePanel.visibility = View.VISIBLE - - eps.forEachIndexed { index, ep -> + private fun buildSourceUI() { + sourceList.removeAllViews() + sources.forEachIndexed { index, source -> val btn = Button(this).apply { - text = ep.title + text = source.name setBackgroundResource(R.drawable.episode_selector) setTextColor(ContextCompat.getColor(this@PlayerActivity, R.color.text_primary)) textSize = 13f @@ -100,9 +107,49 @@ class PlayerActivity : AppCompatActivity() { isFocusable = true isFocusableInTouchMode = true 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) { + 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) playEpisode(ep) + resetAutoHide() } } episodeList.addView(btn) @@ -110,6 +157,7 @@ class PlayerActivity : AppCompatActivity() { } private fun playEpisode(ep: Episode) { + currentEpisode = ep showLoading(true) val config = configRepo.getConfig() @@ -130,10 +178,13 @@ class PlayerActivity : AppCompatActivity() { lifecycleScope.launch { val (directUrl, iframeUrl) = videoExtractor.extractFromPlayPage(detailUrl, config) 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) } } @@ -186,15 +237,38 @@ class PlayerActivity : AppCompatActivity() { } private fun setupTouchListeners() { - var panelVisible = true - playerView.setOnClickListener { - panelVisible = !panelVisible - episodePanel.visibility = if (panelVisible) View.VISIBLE else View.GONE + val listener = View.OnClickListener { toggleControls() } + playerView.setOnClickListener(listener) + playerWebView.setOnClickListener(listener) + } + + 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() { @@ -204,6 +278,7 @@ class PlayerActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() + hideHandler.removeCallbacks(hideRunnable) exoPlayer?.release() exoPlayer = null } diff --git a/app/src/main/java/com/videoapp/tv/SettingsActivity.kt b/app/src/main/java/com/videoapp/tv/SettingsActivity.kt index 15b6ba3..a66c6dc 100644 --- a/app/src/main/java/com/videoapp/tv/SettingsActivity.kt +++ b/app/src/main/java/com/videoapp/tv/SettingsActivity.kt @@ -20,6 +20,8 @@ class SettingsActivity : AppCompatActivity() { private lateinit var editTitleSelector: EditText private lateinit var editCoverSelector: EditText private lateinit var editLinkSelector: EditText + private lateinit var editSourceSelector: EditText + private lateinit var editSourceEpisodeGroupSelector: EditText private lateinit var btnSave: Button private lateinit var btnRestore: Button @@ -38,6 +40,8 @@ class SettingsActivity : AppCompatActivity() { editTitleSelector = findViewById(R.id.edit_title_selector) editCoverSelector = findViewById(R.id.edit_cover_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) btnRestore = findViewById(R.id.btn_restore) @@ -64,6 +68,8 @@ class SettingsActivity : AppCompatActivity() { editTitleSelector.setText(config.titleSelector) editCoverSelector.setText(config.coverSelector) editLinkSelector.setText(config.linkSelector) + editSourceSelector.setText(config.sourceSelector) + editSourceEpisodeGroupSelector.setText(config.sourceEpisodeGroupSelector) } private fun saveConfig() { @@ -78,7 +84,9 @@ class SettingsActivity : AppCompatActivity() { resultSelector = editResultSelector.text.toString().trim(), titleSelector = editTitleSelector.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) diff --git a/app/src/main/java/com/videoapp/tv/data/SiteConfig.kt b/app/src/main/java/com/videoapp/tv/data/SiteConfig.kt index ed1b543..5f1a988 100644 --- a/app/src/main/java/com/videoapp/tv/data/SiteConfig.kt +++ b/app/src/main/java/com/videoapp/tv/data/SiteConfig.kt @@ -45,6 +45,12 @@ data class SiteConfig( @SerializedName("episode_selector") 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") val iframeSelector: String = ".video iframe", diff --git a/app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt b/app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt index 72f4787..bb7ddb3 100644 --- a/app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt +++ b/app/src/main/java/com/videoapp/tv/engine/VideoExtractor.kt @@ -5,88 +5,68 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jsoup.Jsoup -data class VideoInfo( - val detailTitle: String, - val isSeries: Boolean, - val episodes: List, - val videoUrl: String?, - val iframeUrl: String? -) - data class Episode( val title: String, val playUrl: String ) +data class PlaySource( + val name: String, + val episodes: List +) + class VideoExtractor { suspend fun extractVideos( detailUrl: String, config: SiteConfig - ): VideoInfo = withContext(Dispatchers.IO) { + ): List = withContext(Dispatchers.IO) { val doc = Jsoup.connect(detailUrl).timeout(15000).get() - val pageTitle = doc.title() - val episodes = mutableListOf() - var iframeUrl: String? = null - var directVideoUrl: String? = null + // Extract source tabs + val sourceTabs = doc.select(config.sourceSelector) + val sourceNames = sourceTabs.map { it.text().trim() }.filter { it.isNotEmpty() } - // Extract episodes - val episodeEls = doc.select(config.episodeSelector) - for (ep in episodeEls) { + // Extract episode groups (parallel to source tabs) + val episodeGroups = doc.select(config.sourceEpisodeGroupSelector) + + val sources = mutableListOf() + + 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 { + val episodes = mutableListOf() + val eps = container.select(config.episodeSelector) + for (ep in eps) { val title = ep.text().trim() - val href = ep.attr("href")?.trim() ?: "" + val href = ep.attr("href").trim() if (title.isNotEmpty() && href.isNotEmpty()) { val fullUrl = if (href.startsWith("http")) href else config.baseUrl.trimEnd('/') + "/" + href.trimStart('/') episodes.add(Episode(title, fullUrl)) } } - - val isSeries = episodes.size > 1 - - // If episodes found, try to extract video from first episode's play page - if (episodes.isNotEmpty()) { - val playUrl = episodes.first().playUrl - try { - val playDoc = Jsoup.connect(playUrl).timeout(15000).get() - - // Try iframe first - val iframeEl = playDoc.selectFirst(config.iframeSelector) - if (iframeEl != null) { - iframeUrl = iframeEl.attr("src") - if (iframeUrl != null && iframeUrl.startsWith("//")) { - iframeUrl = "https:$iframeUrl" - } - } - - // Try direct video tag - val videoSrc = playDoc.selectFirst(config.videoSelector) - if (videoSrc != null) { - directVideoUrl = videoSrc.attr("src") - } - } catch (e: Exception) { - // extraction failed, will fallback to WebView - } - } else { - // No episodes found - might be a movie (single play button) - // Try to extract video directly from the detail page - val iframeEl = doc.selectFirst(config.iframeSelector) - if (iframeEl != null) { - iframeUrl = iframeEl.attr("src") - if (iframeUrl != null && iframeUrl.startsWith("//")) { - iframeUrl = "https:$iframeUrl" - } - } - } - - VideoInfo( - detailTitle = pageTitle, - isSeries = isSeries, - episodes = episodes, - videoUrl = directVideoUrl, - iframeUrl = iframeUrl - ) + return episodes } suspend fun extractFromPlayPage( diff --git a/app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt b/app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt index dbcd17e..a43970f 100644 --- a/app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt +++ b/app/src/main/java/com/videoapp/tv/ui/SearchFragment.kt @@ -19,7 +19,7 @@ import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.videoapp.tv.R -import com.videoapp.tv.BrowserActivity +import com.videoapp.tv.PlayerActivity import com.videoapp.tv.SettingsActivity import com.videoapp.tv.data.AppDatabase import com.videoapp.tv.data.SearchHistory @@ -185,9 +185,10 @@ class SearchFragment : Fragment() { } private fun openPlayer(result: SearchResult) { - val intent = Intent(requireContext(), BrowserActivity::class.java).apply { - putExtra("url", result.detailUrl) + val intent = Intent(requireContext(), PlayerActivity::class.java).apply { + putExtra("detail_url", result.detailUrl) putExtra("title", result.title) + putExtra("category", result.category) } startActivity(intent) } diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index b5f34a9..725bb50 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -29,18 +29,36 @@ android:scaleType="centerInside" android:contentDescription="@string/back" /> - + android:visibility="visible"> + + + + + @@ -50,10 +68,10 @@ android:layout_height="match_parent" android:gravity="center_vertical" android:orientation="horizontal" - android:paddingStart="16dp" - android:paddingEnd="16dp" /> + android:paddingStart="12dp" + android:paddingEnd="12dp" /> - + + + + + + + + +