Initial release

This commit is contained in:
renato97
2026-03-10 16:28:04 -03:00
commit c1caef7a96
46 changed files with 3404 additions and 0 deletions

View 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()
}
}
}