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(R.id.retry_button).setOnClickListener { retryPlayback() } // Set up close button findViewById(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() } } }