622 lines
19 KiB
Kotlin
622 lines
19 KiB
Kotlin
package com.futbollibre.tv
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
import android.util.Log
|
|
import android.view.KeyEvent
|
|
import android.view.View
|
|
import android.view.WindowManager
|
|
import android.widget.ImageView
|
|
import android.widget.ProgressBar
|
|
import android.widget.TextView
|
|
import android.widget.Toast
|
|
import androidx.core.view.isVisible
|
|
import androidx.fragment.app.FragmentActivity
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.media3.common.C
|
|
import androidx.media3.common.PlaybackException
|
|
import androidx.media3.common.Player
|
|
import androidx.media3.exoplayer.ExoPlayer
|
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
|
import androidx.media3.ui.AspectRatioFrameLayout
|
|
import androidx.media3.ui.PlayerView
|
|
import com.futbollibre.tv.model.StreamType
|
|
import com.futbollibre.tv.model.StreamUrl
|
|
import com.futbollibre.tv.player.ExoPlayerManager
|
|
import com.futbollibre.tv.player.addStateListener
|
|
import com.futbollibre.tv.player.PlayerStateListener
|
|
import com.futbollibre.tv.repository.StreamRepository
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.Job
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* Activity for playing HLS/m3u8 streams
|
|
* Supports Android TV remote control and various player controls
|
|
*/
|
|
class PlayerActivity : FragmentActivity(), PlayerStateListener {
|
|
|
|
companion object {
|
|
private const val TAG = "PlayerActivity"
|
|
|
|
// Intent extras
|
|
const val EXTRA_STREAM_URL = "stream_url"
|
|
const val EXTRA_STREAM_TITLE = "stream_title"
|
|
const val EXTRA_REFERER = "referer"
|
|
const val EXTRA_CHANNEL_ID = "channel_id"
|
|
|
|
// Aspect ratio modes
|
|
private const val ASPECT_RATIO_FIT = 0
|
|
private const val ASPECT_RATIO_FILL = 1
|
|
private const val ASPECT_RATIO_ZOOM = 2
|
|
|
|
// UI hide delay
|
|
private const val UI_HIDE_DELAY_MS = 5000L
|
|
|
|
/**
|
|
* Creates an intent to start PlayerActivity
|
|
*/
|
|
fun createIntent(
|
|
context: Context,
|
|
streamUrl: String,
|
|
title: String? = null,
|
|
referer: String? = null,
|
|
channelId: String? = null
|
|
): Intent {
|
|
return Intent(context, PlayerActivity::class.java).apply {
|
|
putExtra(EXTRA_STREAM_URL, streamUrl)
|
|
putExtra(EXTRA_STREAM_TITLE, title)
|
|
putExtra(EXTRA_REFERER, referer)
|
|
putExtra(EXTRA_CHANNEL_ID, channelId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Views
|
|
private lateinit var playerView: PlayerView
|
|
private lateinit var progressBar: ProgressBar
|
|
private lateinit var errorContainer: View
|
|
private lateinit var errorMessage: TextView
|
|
private lateinit var controlsOverlay: View
|
|
private lateinit var titleTextView: TextView
|
|
private lateinit var infoTextView: TextView
|
|
private lateinit var aspectRatioButton: ImageView
|
|
|
|
// Player components
|
|
private var player: ExoPlayer? = null
|
|
private val playerManager = ExoPlayerManager.getInstance()
|
|
private lateinit var trackSelector: DefaultTrackSelector
|
|
|
|
// State
|
|
private var currentAspectRatioMode = ASPECT_RATIO_FIT
|
|
private var streamUrl: String? = null
|
|
private var streamTitle: String? = null
|
|
private var referer: String? = null
|
|
private var hasError = false
|
|
private var uiHideJob: Job? = null
|
|
private var isControlsVisible = false
|
|
|
|
// Repository for extracting streams
|
|
private val streamRepository = StreamRepository()
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
setContentView(R.layout.activity_player)
|
|
|
|
// Keep screen on during playback
|
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
|
|
// Hide system UI for immersive experience
|
|
hideSystemUI()
|
|
|
|
// Get intent extras
|
|
parseIntent(intent)
|
|
|
|
// Initialize views
|
|
initViews()
|
|
|
|
// Initialize player
|
|
initializePlayer()
|
|
|
|
// Load stream
|
|
loadStream()
|
|
}
|
|
|
|
private fun initViews() {
|
|
playerView = findViewById(R.id.player_view)
|
|
progressBar = findViewById(R.id.progress_bar)
|
|
errorContainer = findViewById(R.id.error_container)
|
|
errorMessage = findViewById(R.id.error_message)
|
|
controlsOverlay = findViewById(R.id.controls_overlay)
|
|
titleTextView = findViewById(R.id.title_text)
|
|
infoTextView = findViewById(R.id.info_text)
|
|
aspectRatioButton = findViewById(R.id.aspect_ratio_button)
|
|
|
|
// Set up aspect ratio button
|
|
aspectRatioButton.setOnClickListener {
|
|
cycleAspectRatio()
|
|
}
|
|
|
|
// Set up retry button
|
|
findViewById<View>(R.id.retry_button).setOnClickListener {
|
|
retryPlayback()
|
|
}
|
|
|
|
// Set up close button
|
|
findViewById<View>(R.id.close_button).setOnClickListener {
|
|
finish()
|
|
}
|
|
|
|
// Configure PlayerView for TV
|
|
playerView.apply {
|
|
// Use controller for TV navigation
|
|
controllerShowTimeoutMs = UI_HIDE_DELAY_MS.toInt()
|
|
controllerHideOnTouch = true
|
|
useController = true
|
|
setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
|
|
// Keep screen on during playback
|
|
keepScreenOn = true
|
|
}
|
|
|
|
// Show title if available
|
|
streamTitle?.let {
|
|
titleTextView.text = it
|
|
titleTextView.isVisible = true
|
|
}
|
|
}
|
|
|
|
private fun parseIntent(intent: Intent) {
|
|
streamUrl = intent.getStringExtra(EXTRA_STREAM_URL)
|
|
streamTitle = intent.getStringExtra(EXTRA_STREAM_TITLE)
|
|
referer = intent.getStringExtra(EXTRA_REFERER)
|
|
|
|
Log.d(TAG, "Stream URL: $streamUrl")
|
|
Log.d(TAG, "Stream Title: $streamTitle")
|
|
Log.d(TAG, "Referer: $referer")
|
|
}
|
|
|
|
private fun initializePlayer() {
|
|
Log.d(TAG, "Initializing ExoPlayer")
|
|
|
|
// Create track selector for subtitle/audio track selection
|
|
trackSelector = DefaultTrackSelector(this)
|
|
|
|
// Create player
|
|
player = playerManager.createPlayer(this, trackSelector)
|
|
|
|
// Add state listener
|
|
player?.addStateListener(this)
|
|
|
|
// Attach to view
|
|
playerView.player = player
|
|
|
|
Log.d(TAG, "ExoPlayer initialized")
|
|
}
|
|
|
|
private fun loadStream() {
|
|
val url = streamUrl
|
|
|
|
if (url.isNullOrBlank()) {
|
|
showError("URL de stream no valida")
|
|
return
|
|
}
|
|
|
|
showLoading()
|
|
hasError = false
|
|
|
|
when {
|
|
url.contains(".m3u8", ignoreCase = true) -> {
|
|
playResolvedStream(
|
|
StreamUrl(
|
|
url = url,
|
|
referer = referer,
|
|
streamType = StreamType.HLS
|
|
)
|
|
)
|
|
}
|
|
url.contains(".mpd", ignoreCase = true) -> {
|
|
playResolvedStream(
|
|
StreamUrl(
|
|
url = url,
|
|
referer = referer,
|
|
streamType = StreamType.DASH
|
|
)
|
|
)
|
|
}
|
|
else -> {
|
|
extractAndPlayUrl(url)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun playResolvedStream(streamUrl: StreamUrl) {
|
|
Log.d(TAG, "Playing ${streamUrl.streamType} URL: ${streamUrl.url}")
|
|
playerManager.prepareStream(player!!, streamUrl, this@PlayerActivity)
|
|
}
|
|
|
|
private fun extractAndPlayUrl(pageUrl: String) {
|
|
Log.d(TAG, "Extracting stream from page: $pageUrl")
|
|
|
|
lifecycleScope.launch {
|
|
val result = streamRepository.extractStreamUrl(pageUrl)
|
|
|
|
result.fold(
|
|
onSuccess = { resolvedStream ->
|
|
Log.d(TAG, "Successfully resolved stream: ${resolvedStream.url}")
|
|
withContext(Dispatchers.Main) {
|
|
playResolvedStream(resolvedStream)
|
|
}
|
|
},
|
|
onFailure = { error ->
|
|
Log.e(TAG, "Failed to resolve stream", error)
|
|
withContext(Dispatchers.Main) {
|
|
showError("No se pudo obtener el stream: ${error.message}")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private fun showLoading() {
|
|
progressBar.isVisible = true
|
|
errorContainer.isVisible = false
|
|
controlsOverlay.isVisible = false
|
|
}
|
|
|
|
private fun showError(message: String) {
|
|
hasError = true
|
|
progressBar.isVisible = false
|
|
errorContainer.isVisible = true
|
|
errorMessage.text = message
|
|
controlsOverlay.isVisible = true
|
|
|
|
Log.e(TAG, "Player error: $message")
|
|
}
|
|
|
|
private fun hideError() {
|
|
errorContainer.isVisible = false
|
|
hasError = false
|
|
}
|
|
|
|
private fun retryPlayback() {
|
|
hideError()
|
|
loadStream()
|
|
}
|
|
|
|
private fun cycleAspectRatio() {
|
|
currentAspectRatioMode = (currentAspectRatioMode + 1) % 3
|
|
|
|
when (currentAspectRatioMode) {
|
|
ASPECT_RATIO_FIT -> {
|
|
playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
|
showToast("Ajustar a pantalla")
|
|
}
|
|
ASPECT_RATIO_FILL -> {
|
|
playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
|
|
showToast("Llenar pantalla")
|
|
}
|
|
ASPECT_RATIO_ZOOM -> {
|
|
playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
|
showToast("Zoom")
|
|
}
|
|
}
|
|
|
|
scheduleUiHide()
|
|
}
|
|
|
|
private fun showToast(message: String) {
|
|
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
|
}
|
|
|
|
// PlayerStateListener implementation
|
|
override fun onLoadingChanged(isLoading: Boolean) {
|
|
Log.d(TAG, "Loading changed: $isLoading")
|
|
progressBar.isVisible = isLoading
|
|
}
|
|
|
|
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
|
|
val stateString = when (playbackState) {
|
|
Player.STATE_IDLE -> "IDLE"
|
|
Player.STATE_BUFFERING -> "BUFFERING"
|
|
Player.STATE_READY -> "READY"
|
|
Player.STATE_ENDED -> "ENDED"
|
|
else -> "UNKNOWN"
|
|
}
|
|
Log.d(TAG, "Player state: $stateString, playWhenReady: $playWhenReady")
|
|
|
|
when (playbackState) {
|
|
Player.STATE_READY -> {
|
|
progressBar.isVisible = false
|
|
hideError()
|
|
showControls()
|
|
updateInfo()
|
|
}
|
|
Player.STATE_ENDED -> {
|
|
showToast("Reproduccion finalizada")
|
|
}
|
|
Player.STATE_BUFFERING -> {
|
|
progressBar.isVisible = true
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onPlayerError(error: PlaybackException) {
|
|
Log.e(TAG, "Player error", error)
|
|
|
|
val rootCauseMessage = buildString {
|
|
var current: Throwable? = error
|
|
while (current != null) {
|
|
if (isNotEmpty()) append(" | ")
|
|
append(current.message.orEmpty())
|
|
current = current.cause
|
|
}
|
|
}
|
|
|
|
val errorMessage = when (error.errorCode) {
|
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> "Error de conexion a internet"
|
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
|
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> "Error al procesar el stream"
|
|
PlaybackException.ERROR_CODE_IO_NO_PERMISSION -> "Sin permisos para acceder al stream"
|
|
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> "Error HTTP: ${error.message}"
|
|
PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> "Stream en vivo no disponible"
|
|
PlaybackException.ERROR_CODE_DRM_UNSPECIFIED,
|
|
PlaybackException.ERROR_CODE_DRM_SCHEME_UNSUPPORTED,
|
|
PlaybackException.ERROR_CODE_DRM_CONTENT_ERROR,
|
|
PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR -> "Este stream usa un cifrado DRM que tu Chromecast no soporta. Proba otra opcion."
|
|
else -> "Error de reproduccion: ${error.message}"
|
|
}
|
|
|
|
if (rootCauseMessage.contains("ERROR_DRM_CANNOT_HANDLE", ignoreCase = true) ||
|
|
rootCauseMessage.contains("selected encryption mode is not supported", ignoreCase = true) ||
|
|
rootCauseMessage.contains("MissingSchemeDataException", ignoreCase = true)
|
|
) {
|
|
showError("Este stream usa un cifrado DRM que tu Chromecast no soporta. Proba otra opcion.")
|
|
return
|
|
}
|
|
|
|
showError(errorMessage)
|
|
}
|
|
|
|
override fun onTracksChanged(hasVideo: Boolean, hasAudio: Boolean) {
|
|
Log.d(TAG, "Tracks changed - Video: $hasVideo, Audio: $hasAudio")
|
|
|
|
if (!hasVideo && !hasAudio) {
|
|
showError("No se encontraron pistas de video/audio")
|
|
}
|
|
}
|
|
|
|
private fun showControls() {
|
|
if (!isControlsVisible) {
|
|
isControlsVisible = true
|
|
controlsOverlay.isVisible = true
|
|
playerView.showController()
|
|
}
|
|
scheduleUiHide()
|
|
}
|
|
|
|
private fun hideControls() {
|
|
if (isControlsVisible) {
|
|
isControlsVisible = false
|
|
controlsOverlay.isVisible = false
|
|
playerView.hideController()
|
|
}
|
|
}
|
|
|
|
private fun scheduleUiHide() {
|
|
uiHideJob?.cancel()
|
|
uiHideJob = lifecycleScope.launch {
|
|
delay(UI_HIDE_DELAY_MS)
|
|
if (!hasError) {
|
|
hideControls()
|
|
}
|
|
}
|
|
}
|
|
|
|
@SuppressLint("SetTextI18n")
|
|
private fun updateInfo() {
|
|
player?.let { exoPlayer ->
|
|
val duration = exoPlayer.duration
|
|
val position = exoPlayer.currentPosition
|
|
|
|
val durationStr = formatTime(duration)
|
|
val positionStr = formatTime(position)
|
|
|
|
infoTextView.text = "$positionStr / $durationStr"
|
|
}
|
|
}
|
|
|
|
private fun formatTime(ms: Long): String {
|
|
if (ms == C.TIME_UNSET || ms < 0) return "--:--"
|
|
|
|
val totalSeconds = ms / 1000
|
|
val hours = totalSeconds / 3600
|
|
val minutes = (totalSeconds % 3600) / 60
|
|
val seconds = totalSeconds % 60
|
|
|
|
return if (hours > 0) {
|
|
String.format("%02d:%02d:%02d", hours, minutes, seconds)
|
|
} else {
|
|
String.format("%02d:%02d", minutes, seconds)
|
|
}
|
|
}
|
|
|
|
@SuppressLint("InlinedApi")
|
|
private fun hideSystemUI() {
|
|
window.decorView.systemUiVisibility = (
|
|
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
|
or View.SYSTEM_UI_FLAG_FULLSCREEN
|
|
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
|
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
|
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
|
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
|
)
|
|
}
|
|
|
|
// Handle TV remote and keyboard input
|
|
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
|
when (keyCode) {
|
|
KeyEvent.KEYCODE_DPAD_CENTER,
|
|
KeyEvent.KEYCODE_ENTER -> {
|
|
togglePlayPause()
|
|
return true
|
|
}
|
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
|
togglePlayPause()
|
|
return true
|
|
}
|
|
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
|
player?.play()
|
|
return true
|
|
}
|
|
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
|
player?.pause()
|
|
return true
|
|
}
|
|
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
|
player?.stop()
|
|
finish()
|
|
return true
|
|
}
|
|
KeyEvent.KEYCODE_DPAD_LEFT,
|
|
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
|
seekBackward()
|
|
return true
|
|
}
|
|
KeyEvent.KEYCODE_DPAD_RIGHT,
|
|
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
|
seekForward()
|
|
return true
|
|
}
|
|
KeyEvent.KEYCODE_DPAD_UP -> {
|
|
showControls()
|
|
return true
|
|
}
|
|
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
|
showControls()
|
|
return true
|
|
}
|
|
KeyEvent.KEYCODE_BACK -> {
|
|
if (isControlsVisible && !hasError) {
|
|
hideControls()
|
|
return true
|
|
}
|
|
}
|
|
KeyEvent.KEYCODE_INFO -> {
|
|
showControls()
|
|
return true
|
|
}
|
|
KeyEvent.KEYCODE_CAPTIONS -> {
|
|
toggleSubtitles()
|
|
return true
|
|
}
|
|
KeyEvent.KEYCODE_A -> {
|
|
cycleAspectRatio()
|
|
return true
|
|
}
|
|
}
|
|
return super.onKeyDown(keyCode, event)
|
|
}
|
|
|
|
private fun togglePlayPause() {
|
|
player?.let {
|
|
if (it.isPlaying) {
|
|
it.pause()
|
|
showToast("Pausado")
|
|
} else {
|
|
it.play()
|
|
showToast("Reproduciendo")
|
|
}
|
|
}
|
|
showControls()
|
|
}
|
|
|
|
private fun seekForward() {
|
|
player?.let {
|
|
val newPosition = it.currentPosition + 10_000 // 10 seconds
|
|
val duration = it.duration
|
|
if (duration != C.TIME_UNSET && newPosition < duration) {
|
|
it.seekTo(newPosition)
|
|
showToast("+10 segundos")
|
|
}
|
|
}
|
|
showControls()
|
|
}
|
|
|
|
private fun seekBackward() {
|
|
player?.let {
|
|
val newPosition = it.currentPosition - 10_000 // 10 seconds
|
|
if (newPosition >= 0) {
|
|
it.seekTo(newPosition)
|
|
showToast("-10 segundos")
|
|
} else {
|
|
it.seekTo(0)
|
|
}
|
|
}
|
|
showControls()
|
|
}
|
|
|
|
private fun toggleSubtitles() {
|
|
// TODO: Implement subtitle track selection
|
|
showToast("Subtitulos no disponibles")
|
|
}
|
|
|
|
override fun onStart() {
|
|
super.onStart()
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
player?.playWhenReady = true
|
|
}
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
player?.playWhenReady = true
|
|
}
|
|
hideSystemUI()
|
|
}
|
|
|
|
override fun onPause() {
|
|
super.onPause()
|
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
|
player?.playWhenReady = false
|
|
}
|
|
}
|
|
|
|
override fun onStop() {
|
|
super.onStop()
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
player?.playWhenReady = false
|
|
}
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
super.onDestroy()
|
|
Log.d(TAG, "Destroying PlayerActivity")
|
|
|
|
// Cancel any pending jobs
|
|
uiHideJob?.cancel()
|
|
|
|
// Release player
|
|
player?.let {
|
|
playerManager.releasePlayer(it)
|
|
player = null
|
|
}
|
|
|
|
Log.d(TAG, "PlayerActivity destroyed")
|
|
}
|
|
|
|
// Handle new intents (for deep links)
|
|
override fun onNewIntent(intent: Intent?) {
|
|
super.onNewIntent(intent)
|
|
intent?.let {
|
|
parseIntent(it)
|
|
loadStream()
|
|
}
|
|
}
|
|
}
|