473 lines
16 KiB
Kotlin
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
|
|
}
|
|
}
|