Initial release
This commit is contained in:
600
app/src/main/java/com/futbollibre/tv/PlayerActivity.kt
Normal file
600
app/src/main/java/com/futbollibre/tv/PlayerActivity.kt
Normal file
@@ -0,0 +1,600 @@
|
||||
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 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"
|
||||
else -> "Error de reproduccion: ${error.message}"
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user