Files
android-tv/app/src/main/java/com/videoapp/tv/PlayerActivity.kt

473 lines
16 KiB
Kotlin

package com.videoapp.tv
import android.content.Context
import android.media.AudioManager
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.HorizontalScrollView
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import com.videoapp.tv.data.AppDatabase
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 kotlinx.coroutines.launch
class PlayerActivity : AppCompatActivity() {
private lateinit var playerView: PlayerView
private lateinit var playerWebView: WebView
private lateinit var controlPanel: View
private lateinit var sourceList: LinearLayout
private lateinit var episodeList: LinearLayout
private lateinit var episodeScroll: HorizontalScrollView
private lateinit var loadingIndicator: View
private lateinit var errorText: TextView
private lateinit var btnClose: ImageButton
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 }
private var sources: List<PlaySource> = emptyList()
private var currentSourceIndex = 0
private var currentEpisode: Episode? = null
private var historyEpisode: String? = null
private var resumePosition: Long? = null
private var currentPlayHistoryId: Long? = null
private val hideHandler = Handler(Looper.getMainLooper())
private val hideRunnable = Runnable { hideControls() }
private var controlsVisible = true
private val positionSaveHandler = Handler(Looper.getMainLooper())
private val positionSaveRunnable = object : Runnable {
override fun run() {
saveCurrentPosition()
positionSaveHandler.postDelayed(this, 5000)
}
}
private var videoTitle: String = ""
private var videoCategory: String? = null
private var coverUrl: String? = null
private var detailUrl: String = ""
private var touchStartY = 0f
private var touchStartX = 0f
private var isAdjusting = false
private var isBrightnessMode = false
private var startBrightness = 0f
private var startVolume = 0
private var maxVolume = 0
private val indicatorHideHandler = Handler(Looper.getMainLooper())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_player)
playerView = findViewById(R.id.player_view)
playerWebView = findViewById(R.id.player_webview)
controlPanel = findViewById(R.id.control_panel)
sourceList = findViewById(R.id.source_list)
episodeList = findViewById(R.id.episode_list)
episodeScroll = findViewById(R.id.episode_scroll)
loadingIndicator = findViewById(R.id.loading_indicator)
errorText = findViewById(R.id.error_text)
btnClose = findViewById(R.id.btn_close)
brightnessVolumeIndicator = findViewById(R.id.brightness_volume_indicator)
detailUrl = intent.getStringExtra("detail_url") ?: ""
videoTitle = intent.getStringExtra("title") ?: ""
videoCategory = intent.getStringExtra("category")
coverUrl = intent.getStringExtra("cover_url")
historyEpisode = intent.getStringExtra("history_episode")
resumePosition = if (intent.hasExtra("resume_position")) intent.getLongExtra("resume_position", 0) else null
maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
btnClose.setOnClickListener { finish() }
initExoPlayer()
loadSources(detailUrl)
setupTouchListeners()
}
private fun initExoPlayer() {
exoPlayer = ExoPlayer.Builder(this).build().also { player ->
playerView.player = player
player.addListener(object : Player.Listener {
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
if (playWhenReady) {
hideHandler.postDelayed(hideRunnable, 4000)
} else {
hideHandler.removeCallbacks(hideRunnable)
showControls()
}
}
})
}
}
private fun loadSources(detailUrl: String) {
showLoading(true)
val config = configRepo.getConfig()
lifecycleScope.launch {
try {
sources = videoExtractor.extractVideos(detailUrl, config)
if (sources.isNotEmpty()) {
buildSourceUI()
selectSource(0)
resetAutoHide()
} else {
tryPlayDirectly(detailUrl, config)
}
} catch (e: Exception) {
showError("加载失败: ${e.message}")
showLoading(false)
}
}
}
private fun buildSourceUI() {
sourceList.removeAllViews()
sources.forEachIndexed { index, source ->
val btn = Button(this).apply {
text = source.name
setBackgroundResource(R.drawable.episode_selector)
setTextColor(ContextCompat.getColor(this@PlayerActivity, R.color.text_primary))
textSize = 13f
minWidth = 0
setPadding(16, 8, 16, 8)
isFocusable = true
isFocusableInTouchMode = true
setOnClickListener {
selectSource(index)
resetAutoHide()
}
}
sourceList.addView(btn)
}
}
private fun selectSource(index: Int) {
if (index !in sources.indices) return
currentSourceIndex = index
for (i in 0 until sourceList.childCount) {
sourceList.getChildAt(i).isSelected = (i == index)
}
val source = sources[index]
buildEpisodeUI(source.episodes)
if (historyEpisode != null) {
scrollToEpisode(historyEpisode!!)
} else {
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)
playEpisode(ep)
resetAutoHide()
}
}
episodeList.addView(btn)
}
}
private fun scrollToEpisode(episodeTitle: String) {
episodeScroll.post {
for (i in 0 until episodeList.childCount) {
val child = episodeList.getChildAt(i) as? Button
if (child?.text == episodeTitle) {
val left = child.left - episodeList.paddingStart
episodeScroll.smoothScrollTo(left, 0)
child.isSelected = true
break
}
}
}
}
private fun playEpisode(ep: Episode) {
currentEpisode = ep
showLoading(true)
val config = configRepo.getConfig()
savePlayHistory(ep.title)
lifecycleScope.launch {
val (directUrl, iframeUrl) = videoExtractor.extractFromPlayPage(ep.playUrl, config)
if (directUrl != null) {
playWithExoPlayer(directUrl)
} else if (iframeUrl != null) {
playWithWebView(iframeUrl)
} else {
playWithWebView(ep.playUrl)
}
}
}
private fun savePlayHistory(episodeName: String?) {
lifecycleScope.launch {
try {
val existing = playHistoryDao.findByDetailUrl(detailUrl)
if (existing != null) {
playHistoryDao.updatePlayTime(existing.id, System.currentTimeMillis(), episodeName)
currentPlayHistoryId = existing.id
} else {
val playHistory = PlayHistory(
title = videoTitle,
episodeName = episodeName,
detailUrl = detailUrl,
coverUrl = coverUrl,
category = videoCategory,
playTime = System.currentTimeMillis()
)
val id = playHistoryDao.insert(playHistory)
currentPlayHistoryId = id
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private fun saveCurrentPosition() {
val player = exoPlayer ?: return
val historyId = currentPlayHistoryId ?: return
if (playerView.visibility != View.VISIBLE) return
val pos = player.currentPosition
if (pos > 0) {
lifecycleScope.launch {
try {
playHistoryDao.updatePosition(historyId, pos)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
private fun startPositionSaving() {
positionSaveHandler.removeCallbacks(positionSaveRunnable)
positionSaveHandler.postDelayed(positionSaveRunnable, 5000)
}
private fun stopPositionSaving() {
positionSaveHandler.removeCallbacks(positionSaveRunnable)
}
private fun tryPlayDirectly(detailUrl: String, config: com.videoapp.tv.data.SiteConfig) {
savePlayHistory(videoTitle)
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)
}
}
}
private fun playWithExoPlayer(url: String) {
playerWebView.visibility = View.GONE
playerView.visibility = View.VISIBLE
showLoading(false)
val mediaItem = MediaItem.fromUri(url)
exoPlayer?.apply {
setMediaItem(mediaItem)
prepare()
playWhenReady = true
if (resumePosition != null && resumePosition!! > 0) {
seekTo(resumePosition!!)
resumePosition = null
}
}
startPositionSaving()
}
private fun playWithWebView(url: String) {
stopPositionSaving()
playerView.visibility = View.GONE
playerWebView.visibility = View.VISIBLE
showLoading(false)
playerWebView.settings.javaScriptEnabled = true
playerWebView.settings.domStorageEnabled = true
playerWebView.settings.mediaPlaybackRequiresUserGesture = false
playerWebView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
showLoading(false)
}
}
playerWebView.loadUrl(url)
}
private fun highlightEpisode(view: View) {
for (i in 0 until episodeList.childCount) {
episodeList.getChildAt(i).isSelected = (episodeList.getChildAt(i) == view)
}
}
private fun showLoading(show: Boolean) {
loadingIndicator.visibility = if (show) View.VISIBLE else View.GONE
}
private fun showError(msg: String) {
errorText.text = msg
errorText.visibility = View.VISIBLE
showLoading(false)
}
private fun setupTouchListeners() {
val touchListener = View.OnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
touchStartX = event.x
touchStartY = event.y
isAdjusting = false
val halfWidth = view.width / 2
isBrightnessMode = touchStartX < halfWidth
startBrightness = window.attributes.screenBrightness
if (startBrightness < 0) startBrightness = 0.5f
startVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
true
}
MotionEvent.ACTION_MOVE -> {
val deltaY = touchStartY - event.y
if (kotlin.math.abs(event.y - touchStartY) > 10f) {
isAdjusting = true
}
if (isAdjusting) {
if (isBrightnessMode) {
val newBrightness = (startBrightness + deltaY / view.height).coerceIn(0.01f, 1.0f)
window.attributes.screenBrightness = newBrightness
window.attributes = window.attributes
showIndicator("亮度", (newBrightness * 100).toInt())
} else {
val range = maxVolume.toFloat()
val newVolume = (startVolume + (deltaY / view.height) * range).toInt().coerceIn(0, maxVolume)
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0)
showIndicator("音量", (newVolume * 100 / maxVolume))
}
}
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
val movedFar = kotlin.math.abs(event.x - touchStartX) > 20f || kotlin.math.abs(event.y - touchStartY) > 20f
if (!isAdjusting && !movedFar) {
toggleControls()
}
true
}
else -> false
}
}
playerView.setOnTouchListener(touchListener)
playerWebView.setOnTouchListener(touchListener)
}
private fun showIndicator(label: String, percent: Int) {
brightnessVolumeIndicator.text = "$label: $percent%"
brightnessVolumeIndicator.visibility = View.VISIBLE
indicatorHideHandler.removeCallbacksAndMessages(null)
indicatorHideHandler.postDelayed({
brightnessVolumeIndicator.visibility = View.GONE
}, 1500)
}
private fun toggleControls() {
if (controlsVisible) {
hideControls()
} else {
showControls()
resetAutoHide()
}
}
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() {
super.onPause()
saveCurrentPosition()
stopPositionSaving()
exoPlayer?.playWhenReady = false
}
override fun onDestroy() {
super.onDestroy()
saveCurrentPosition()
stopPositionSaving()
hideHandler.removeCallbacks(hideRunnable)
exoPlayer?.release()
exoPlayer = null
}
}