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

72
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,72 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.parcelize")
}
android {
namespace = "com.futbollibre.tv"
compileSdk = 35
buildToolsVersion = "36.1.0"
defaultConfig {
applicationId = "com.futbollibre.tv"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("debug")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// Android TV Leanback
implementation("androidx.leanback:leanback:1.0.0")
implementation("androidx.leanback:leanback-preference:1.0.0")
// ConstraintLayout
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
// ExoPlayer for HLS streaming
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-exoplayer-dash:1.2.1")
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
// OkHttp for HTTP requests
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// Jsoup for HTML parsing
implementation("org.jsoup:jsoup:1.17.2")
// Gson for JSON
implementation("com.google.code.gson:gson:2.10.1")
// Coil for image loading
implementation("io.coil-kt:coil:2.5.0")
}

11
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,11 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in the Android SDK tools directory.
# Keep native methods
-keepclasseswithmembernames class * {
native <methods>;
}
# Keep Jsoup
-keep class org.jsoup.** { *; }

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Android TV features -->
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<application
android:name=".FutbolLibreApp"
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.FutbolLibreTV"
android:usesCleartextTraffic="true"
tools:targetApi="34">
<!-- Main Activity (TV Launcher) -->
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Player Activity -->
<activity
android:name=".PlayerActivity"
android:screenOrientation="landscape"
android:theme="@style/Theme.FutbolLibreTV.Player" />
<activity
android:name=".ui.detail.ChannelDetailsActivity"
android:screenOrientation="landscape" />
<!-- Settings Activity -->
<activity
android:name=".SettingsActivity"
android:screenOrientation="landscape" />
</application>
</manifest>

View File

@@ -0,0 +1,120 @@
package com.futbollibre.tv
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter
import coil.imageLoader
import coil.request.ImageRequest
import com.futbollibre.tv.model.Channel
/**
* Presenter for displaying agenda items as cards in the Leanback UI.
*/
class ChannelCardPresenter(
private val context: Context,
private val cardWidth: Int = 300,
private val cardHeight: Int = 200
) : Presenter() {
companion object {
private const val TAG = "ChannelCardPresenter"
}
private var defaultCardImage: Drawable? = null
private var selectedBackgroundColor: Int = 0
private var defaultBackgroundColor: Int = 0
init {
initColors()
}
private fun initColors() {
defaultBackgroundColor = ContextCompat.getColor(context, R.color.card_background)
selectedBackgroundColor = ContextCompat.getColor(context, R.color.card_selected_background)
defaultCardImage = ContextCompat.getDrawable(context, R.drawable.ic_channel_default)
}
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(context).apply {
cardType = ImageCardView.CARD_TYPE_INFO_UNDER
isFocusable = true
isFocusableInTouchMode = true
setMainImageDimensions(cardWidth, cardHeight)
// Set background colors
setBackgroundColor(defaultBackgroundColor)
}
return ViewHolder(cardView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val channel = item as Channel
val cardView = viewHolder.view as ImageCardView
cardView.titleText = channel.name
val contentText = channel.summary ?: if (channel.streamUrls.isNotEmpty()) {
"${channel.streamUrls.size} opciones"
} else {
"Sin opciones"
}
cardView.contentText = contentText
cardView.setBackgroundColor(defaultBackgroundColor)
if (!channel.logoUrl.isNullOrEmpty()) {
loadChannelLogo(cardView, channel.logoUrl)
} else {
cardView.mainImage = defaultCardImage
}
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.badgeImage = null
cardView.mainImage = null
}
override fun onViewAttachedToWindow(viewHolder: ViewHolder) {
super.onViewAttachedToWindow(viewHolder)
}
private fun loadChannelLogo(cardView: ImageCardView, logoUrl: String) {
cardView.mainImage = defaultCardImage
loadImageAsync(cardView, logoUrl)
}
private fun loadImageAsync(cardView: ImageCardView, url: String) {
val imageLoader = context.imageLoader
val request = ImageRequest.Builder(context)
.data(url)
.target(
onStart = {
cardView.mainImage = defaultCardImage
},
onSuccess = { drawable ->
cardView.mainImage = drawable
},
onError = {
cardView.mainImage = defaultCardImage
}
)
.build()
imageLoader.enqueue(request)
}
fun onItemSelected(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.setBackgroundColor(selectedBackgroundColor)
}
fun onItemUnselected(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView
cardView.setBackgroundColor(defaultBackgroundColor)
}
}

View File

@@ -0,0 +1,10 @@
package com.futbollibre.tv
import android.app.Application
class FutbolLibreApp : Application() {
override fun onCreate() {
super.onCreate()
// Initialize any global components here
}
}

View File

@@ -0,0 +1,30 @@
package com.futbollibre.tv
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
/**
* Main Activity for the Android TV app.
* Entry point for the Leanback launcher.
* Uses BrowseSupportFragment for the main UI.
*/
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// The MainFragment is defined in the layout XML
// No need to manually add it here
}
override fun onBackPressed() {
// Handle back navigation with the fragment
val fragment = supportFragmentManager.findFragmentById(R.id.main_browse_fragment) as? MainFragment
if (fragment != null && !fragment.onBackPressed()) {
super.onBackPressed()
} else if (fragment == null) {
super.onBackPressed()
}
}
}

View File

@@ -0,0 +1,237 @@
package com.futbollibre.tv
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.content.Intent
import android.os.Bundle
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.leanback.app.BackgroundManager
import androidx.leanback.app.BrowseSupportFragment
import androidx.leanback.widget.ArrayObjectAdapter
import androidx.leanback.widget.HeaderItem
import androidx.leanback.widget.ListRow
import androidx.leanback.widget.ListRowPresenter
import androidx.leanback.widget.OnItemViewClickedListener
import androidx.leanback.widget.OnItemViewSelectedListener
import androidx.leanback.widget.Presenter
import androidx.lifecycle.lifecycleScope
import com.futbollibre.tv.model.Channel
import com.futbollibre.tv.repository.StreamRepository
import com.futbollibre.tv.ui.detail.ChannelDetailsActivity
import kotlinx.coroutines.launch
/**
* Main Fragment that displays the current agenda using Leanback BrowseSupportFragment.
*/
class MainFragment : BrowseSupportFragment() {
companion object {
private const val TAG = "MainFragment"
private const val GRID_ITEM_WIDTH = 300
private const val GRID_ITEM_HEIGHT = 200
private const val REFRESH_INTERVAL_MS = 5 * 60 * 1000L
}
private val repository = StreamRepository()
private val rowsAdapter = ArrayObjectAdapter(ListRowPresenter())
private lateinit var backgroundManager: BackgroundManager
private var defaultBackground: Drawable? = null
private var lastLoadAt = 0L
private var isLoadingAgenda = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupUI()
setupListeners()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupBackgroundManager()
loadChannels(force = true)
}
override fun onResume() {
super.onResume()
if (System.currentTimeMillis() - lastLoadAt >= REFRESH_INTERVAL_MS) {
loadChannels(force = true)
}
}
private fun setupUI() {
title = getString(R.string.events)
headersState = HEADERS_ENABLED
isHeadersTransitionOnBackEnabled = true
brandColor = ContextCompat.getColor(requireContext(), R.color.primary)
searchAffordanceColor = ContextCompat.getColor(requireContext(), R.color.primary_dark)
}
private fun setupBackgroundManager() {
backgroundManager = BackgroundManager.getInstance(requireActivity())
backgroundManager.attach(requireActivity().window)
defaultBackground = ContextCompat.getDrawable(requireContext(), R.drawable.default_background)
backgroundManager.drawable = defaultBackground
}
private fun setupListeners() {
onItemViewClickedListener = OnItemViewClickedListener { _, item, _, _ ->
if (item is Channel) {
Log.d(TAG, "Event clicked: ${item.name}")
openDetailsActivity(item)
}
}
onItemViewSelectedListener = OnItemViewSelectedListener { _, item, _, _ ->
if (item is Channel) {
Log.d(TAG, "Event selected: ${item.name}")
}
}
}
private fun loadChannels(force: Boolean = false) {
if (isLoadingAgenda) {
return
}
if (!force && System.currentTimeMillis() - lastLoadAt < REFRESH_INTERVAL_MS) {
return
}
viewLifecycleOwner.lifecycleScope.launch {
isLoadingAgenda = true
showLoading()
val result = repository.getChannels()
result.fold(
onSuccess = { events ->
lastLoadAt = System.currentTimeMillis()
Log.d(TAG, "Loaded ${events.size} events")
displayChannels(events)
isLoadingAgenda = false
},
onFailure = { error ->
Log.e(TAG, "Error loading agenda", error)
showError(error.message ?: "Error desconocido")
isLoadingAgenda = false
}
)
}
}
private fun showLoading() {
rowsAdapter.clear()
val loadingPresenter = object : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val textView = TextView(parent.context).apply {
text = getString(R.string.loading_events)
textSize = 24f
setTextColor(Color.WHITE)
gravity = Gravity.CENTER
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
return ViewHolder(textView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
}
}
val loadingAdapter = ArrayObjectAdapter(loadingPresenter)
loadingAdapter.add(Any())
val header = HeaderItem(0L, "Cargando")
rowsAdapter.add(ListRow(header, loadingAdapter))
adapter = rowsAdapter
}
private fun displayChannels(channels: List<Channel>) {
rowsAdapter.clear()
if (channels.isEmpty()) {
showError(getString(R.string.no_events))
return
}
val channelsByCategory = channels.groupBy { it.category }
var rowIndex = 0L
channelsByCategory.forEach { (category, categoryChannels) ->
val cardPresenter = ChannelCardPresenter(requireContext(), GRID_ITEM_WIDTH, GRID_ITEM_HEIGHT)
val listRowAdapter = ArrayObjectAdapter(cardPresenter)
categoryChannels.forEach { channel ->
listRowAdapter.add(channel)
}
val header = HeaderItem(rowIndex++, category)
rowsAdapter.add(ListRow(header, listRowAdapter))
}
adapter = rowsAdapter
selectedPosition = 0
}
private fun showError(message: String) {
rowsAdapter.clear()
val errorPresenter = object : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val textView = TextView(parent.context).apply {
text = message
textSize = 20f
setTextColor(Color.RED)
gravity = Gravity.CENTER
setPadding(32, 32, 32, 32)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
return ViewHolder(textView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
}
}
val errorAdapter = ArrayObjectAdapter(errorPresenter)
errorAdapter.add(Any())
val header = HeaderItem(0L, "Error")
rowsAdapter.add(ListRow(header, errorAdapter))
adapter = rowsAdapter
}
private fun openDetailsActivity(channel: Channel) {
val intent = Intent(requireContext(), ChannelDetailsActivity::class.java).apply {
putExtra("channel", channel)
}
startActivity(intent)
}
/**
* Handles back press. Returns true if the fragment handled it.
*/
fun onBackPressed(): Boolean {
// If headers are showing and we're in a row, just let the default behavior happen
return false
}
}

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

View File

@@ -0,0 +1,62 @@
package com.futbollibre.tv
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.fragment.app.FragmentActivity
import com.futbollibre.tv.BuildConfig
/**
* Activity de configuracion para la aplicacion Futbol Libre TV.
* Muestra informacion de la version y creditos.
*/
class SettingsActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
setupVersionInfo()
setupCredits()
}
private fun setupVersionInfo() {
val versionTextView = findViewById<TextView>(R.id.version_text)
val versionName = BuildConfig.VERSION_NAME
val versionCode = BuildConfig.VERSION_CODE
val versionInfo = buildString {
append("Version: $versionName")
append("\n")
append("Codigo de version: $versionCode")
append("\n")
append("Android TV SDK: ${Build.VERSION.SDK_INT}")
}
versionTextView.text = versionInfo
}
private fun setupCredits() {
val creditsTextView = findViewById<TextView>(R.id.credits_text)
val credits = buildString {
append("Futbol Libre TV\n\n")
append("Aplicacion de codigo abierto para ver futbol en vivo en Android TV.\n\n")
append("Desarrollado con amor para la comunidad.\n\n")
append("Caracteristicas:\n")
append("- Transmisiones en vivo\n")
append("- Guia de partidos\n")
append("- Interfaz optimizada para TV\n")
append("- Soporte para control remoto\n\n")
append("Agradecimientos:\n")
append("- Comunidad de Futbol Libre\n")
append("- Desarrolladores de Android TV\n")
append("- ExoPlayer Team\n")
}
creditsTextView.text = credits
}
}

View File

@@ -0,0 +1,49 @@
package com.futbollibre.tv.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Represents an event or playable item with streaming options.
*/
@Parcelize
data class Channel(
val id: String,
val name: String,
val logoUrl: String? = null,
val category: String = "Agenda",
val summary: String? = null,
val startTime: String? = null,
val streamUrls: List<StreamOption> = emptyList()
) : Parcelable
/**
* Represents a streaming option for an event.
*/
@Parcelize
data class StreamOption(
val name: String,
val url: String,
val quality: String = "",
val description: String? = null
) : Parcelable
/**
* Supported stream formats.
*/
enum class StreamType {
HLS,
DASH
}
/**
* Parsed stream URL with playback metadata.
*/
@Parcelize
data class StreamUrl(
val url: String,
val referer: String? = null,
val token: String? = null,
val streamType: StreamType = StreamType.HLS,
val clearKeys: Map<String, String> = emptyMap()
) : Parcelable

View File

@@ -0,0 +1,220 @@
package com.futbollibre.tv.player
import android.content.Context
import android.util.Base64
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector
import com.futbollibre.tv.model.StreamType
import com.futbollibre.tv.model.StreamUrl
/**
* Manager class for ExoPlayer instance.
*/
class ExoPlayerManager private constructor() {
companion object {
private const val TAG = "ExoPlayerManager"
private const val USER_AGENT =
"Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
private const val CONNECT_TIMEOUT_MS = 30_000
private const val READ_TIMEOUT_MS = 30_000
@Volatile
private var instance: ExoPlayerManager? = null
fun getInstance(): ExoPlayerManager {
return instance ?: synchronized(this) {
instance ?: ExoPlayerManager().also { instance = it }
}
}
}
fun createPlayer(
context: Context,
trackSelector: TrackSelector = DefaultTrackSelector(context)
): ExoPlayer {
Log.d(TAG, "Creating ExoPlayer instance")
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(USER_AGENT)
.setConnectTimeoutMs(CONNECT_TIMEOUT_MS)
.setReadTimeoutMs(READ_TIMEOUT_MS)
.setAllowCrossProtocolRedirects(true)
val dataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
return ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
.setTrackSelector(trackSelector)
.build()
.apply {
playWhenReady = true
volume = 1f
Log.d(TAG, "ExoPlayer instance created successfully")
}
}
fun prepareHlsStream(player: ExoPlayer, streamUrl: StreamUrl, context: Context) {
prepareStream(player, streamUrl, context)
}
fun prepareStream(player: ExoPlayer, streamUrl: StreamUrl, context: Context) {
Log.d(TAG, "Preparing ${streamUrl.streamType} stream: ${streamUrl.url}")
val httpDataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(USER_AGENT)
.setConnectTimeoutMs(CONNECT_TIMEOUT_MS)
.setReadTimeoutMs(READ_TIMEOUT_MS)
.setAllowCrossProtocolRedirects(true)
streamUrl.referer?.let { referer ->
Log.d(TAG, "Setting Referer header: $referer")
httpDataSourceFactory.setDefaultRequestProperties(
mapOf(
"Referer" to referer,
"Origin" to extractOrigin(referer)
)
)
}
val dataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
val mediaItemBuilder = MediaItem.Builder()
.setUri(streamUrl.url)
.setMimeType(
when (streamUrl.streamType) {
StreamType.HLS -> MimeTypes.APPLICATION_M3U8
StreamType.DASH -> MimeTypes.APPLICATION_MPD
}
)
if (streamUrl.streamType == StreamType.DASH && streamUrl.clearKeys.isNotEmpty()) {
val drmSessionManager = buildClearKeyDrmSessionManager(streamUrl.clearKeys)
mediaSourceFactory.setDrmSessionManagerProvider { drmSessionManager }
mediaItemBuilder.setDrmConfiguration(
MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID)
.setPlayClearContentWithoutKey(true)
.build()
)
}
val mediaSource = mediaSourceFactory.createMediaSource(mediaItemBuilder.build())
player.setMediaSource(mediaSource)
player.prepare()
Log.d(TAG, "Stream preparation started")
}
fun prepareStream(player: ExoPlayer, url: String, referer: String?, context: Context) {
val streamType = if (url.contains(".mpd", ignoreCase = true)) {
StreamType.DASH
} else {
StreamType.HLS
}
prepareStream(player, StreamUrl(url = url, referer = referer, streamType = streamType), context)
}
fun releasePlayer(player: ExoPlayer?) {
player?.let {
Log.d(TAG, "Releasing ExoPlayer instance")
it.stop()
it.release()
Log.d(TAG, "ExoPlayer released successfully")
}
}
private fun buildClearKeyDrmSessionManager(clearKeys: Map<String, String>): DefaultDrmSessionManager {
val licenseResponse = buildClearKeyLicenseResponse(clearKeys)
val drmCallback = LocalMediaDrmCallback(licenseResponse)
return DefaultDrmSessionManager.Builder()
.setUuidAndExoMediaDrmProvider(C.CLEARKEY_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER)
.setPlayClearSamplesWithoutKeys(true)
.build(drmCallback)
}
private fun buildClearKeyLicenseResponse(clearKeys: Map<String, String>): ByteArray {
val keysJson = clearKeys.entries.joinToString(",") { (kid, key) ->
"""{"kty":"oct","kid":"${hexToBase64Url(kid)}","k":"${hexToBase64Url(key)}"}"""
}
return """{"keys":[$keysJson],"type":"temporary"}""".toByteArray(Charsets.UTF_8)
}
private fun hexToBase64Url(hex: String): String {
val bytes = hex.chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
return Base64.encodeToString(
bytes,
Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
)
}
private fun extractOrigin(url: String): String {
return try {
val uri = android.net.Uri.parse(url)
val scheme = uri.scheme ?: "https"
val host = uri.host ?: ""
val port = uri.port
if (port > 0 && port != 80 && port != 443) {
"$scheme://$host:$port"
} else {
"$scheme://$host"
}
} catch (e: Exception) {
Log.e(TAG, "Error extracting origin from URL", e)
""
}
}
}
interface PlayerStateListener {
fun onLoadingChanged(isLoading: Boolean)
fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int)
fun onPlayerError(error: PlaybackException)
fun onTracksChanged(hasVideo: Boolean, hasAudio: Boolean)
}
fun ExoPlayer.addStateListener(listener: PlayerStateListener) {
addListener(object : Player.Listener {
override fun onIsLoadingChanged(isLoading: Boolean) {
listener.onLoadingChanged(isLoading)
}
override fun onPlaybackStateChanged(playbackState: Int) {
listener.onPlayerStateChanged(playWhenReady, playbackState)
}
override fun onPlayerError(error: PlaybackException) {
listener.onPlayerError(error)
}
override fun onTracksChanged(tracks: Tracks) {
var hasVideo = false
var hasAudio = false
for (group in tracks.groups) {
for (i in 0 until group.length) {
if (group.type == C.TRACK_TYPE_VIDEO) hasVideo = true
if (group.type == C.TRACK_TYPE_AUDIO) hasAudio = true
}
}
listener.onTracksChanged(hasVideo, hasAudio)
}
})
}

View File

@@ -0,0 +1,376 @@
package com.futbollibre.tv.repository
import android.util.Base64
import android.util.Log
import com.futbollibre.tv.model.Channel
import com.futbollibre.tv.model.StreamOption
import com.futbollibre.tv.model.StreamType
import com.futbollibre.tv.model.StreamUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
class StreamRepository {
companion object {
private const val TAG = "StreamRepository"
private const val BASE_URL = "https://futbollibretv.su"
private const val AGENDA_URL = "$BASE_URL/agenda/"
private const val MAX_RESOLUTION_DEPTH = 6
const val USER_AGENT =
"Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
}
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
/**
* Fetches the current agenda from the remote site.
* The page is resolved live on each request, so the app keeps following the
* day-to-day schedule updates without bundling fixed dates in the APK.
*/
suspend fun getChannels(): Result<List<Channel>> = withContext(Dispatchers.IO) {
try {
val html = fetchHtml(AGENDA_URL, referer = BASE_URL)
val events = parseAgendaFromHtml(html)
Result.success(events)
} catch (e: Exception) {
Log.e(TAG, "Error fetching agenda", e)
Result.failure(e)
}
}
suspend fun extractStreamUrl(streamPageUrl: String): Result<StreamUrl> = withContext(Dispatchers.IO) {
try {
resolveStream(streamPageUrl, referer = BASE_URL, depth = 0)
} catch (e: Exception) {
Log.e(TAG, "Error extracting stream", e)
Result.failure(e)
}
}
/**
* Backward-compatible entry point kept for callers that still expect the old name.
*/
suspend fun extractM3u8Url(streamPageUrl: String): Result<StreamUrl> {
return extractStreamUrl(streamPageUrl)
}
private fun parseAgendaFromHtml(html: String): List<Channel> {
val doc = Jsoup.parse(html, AGENDA_URL)
val events = mutableListOf<Channel>()
doc.select("ul.menu > li").forEachIndexed { index, item ->
parseAgendaItem(item, index)?.let(events::add)
}
return events
}
private fun parseAgendaItem(item: Element, index: Int): Channel? {
val headerLink = item.children().firstOrNull { it.tagName() == "a" } ?: return null
val time = headerLink.selectFirst("span.t")?.text()?.trim()
val fullTitle = headerLink.ownText().trim()
if (fullTitle.isBlank()) {
return null
}
val titleParts = fullTitle.split(":", limit = 2)
val category = if (titleParts.size > 1) titleParts[0].trim() else "Agenda"
val name = if (titleParts.size > 1) titleParts[1].trim() else fullTitle
val options = item.select("ul > li > a").mapNotNull { link ->
val url = normalizeUrl(link.attr("href"), AGENDA_URL) ?: return@mapNotNull null
val quality = link.selectFirst("span")?.text()?.trim().orEmpty()
val label = link.ownText().trim()
StreamOption(
name = label.ifBlank { "Opcion" },
url = url,
quality = quality,
description = if (quality.isBlank()) null else quality
)
}
val normalizedName = name.lowercase()
.replace(Regex("[^a-z0-9]+"), "-")
.trim('-')
.ifBlank { "evento-$index" }
val id = buildString {
append(normalizedName)
if (!time.isNullOrBlank()) {
append('-')
append(time.replace(":", ""))
}
}
val summary = listOfNotNull(time, if (options.isEmpty()) "Sin opciones por ahora" else null)
.joinToString(" · ")
.let { value -> value.ifBlank { "" } }
.takeIf { it.isNotBlank() }
return Channel(
id = id,
name = name,
category = category,
summary = summary,
startTime = time,
streamUrls = options
)
}
private fun resolveStream(rawUrl: String, referer: String?, depth: Int): Result<StreamUrl> {
if (depth > MAX_RESOLUTION_DEPTH) {
return Result.failure(Exception("Demasiadas resoluciones internas"))
}
val normalizedUrl = normalizeUrl(rawUrl, referer ?: BASE_URL)
?: return Result.failure(Exception("URL de stream invalida"))
if (isDirectHlsUrl(normalizedUrl)) {
return Result.success(
StreamUrl(
url = normalizedUrl,
referer = referer,
streamType = StreamType.HLS
)
)
}
if (isDirectDashUrl(normalizedUrl)) {
return Result.success(
StreamUrl(
url = normalizedUrl,
referer = referer,
streamType = StreamType.DASH
)
)
}
decodeEmbeddedEventUrl(normalizedUrl)?.let { decodedUrl ->
if (decodedUrl != normalizedUrl) {
return resolveStream(decodedUrl, normalizedUrl, depth + 1)
}
}
val request = buildRequest(normalizedUrl, referer)
val response = client.newCall(request).execute()
response.use {
if (!it.isSuccessful) {
return Result.failure(Exception("HTTP ${it.code}"))
}
val html = it.body?.string().orEmpty()
if (html.isBlank()) {
return Result.failure(Exception("Respuesta vacia"))
}
val finalUrl = it.request.url.toString()
extractDashStream(finalUrl, html)?.let { stream ->
return Result.success(stream)
}
extractHlsStream(finalUrl, html)?.let { stream ->
return Result.success(stream)
}
extractIframeUrl(finalUrl, html)?.let { iframeUrl ->
return resolveStream(iframeUrl, finalUrl, depth + 1)
}
return Result.failure(Exception("No se encontro un stream reproducible"))
}
}
private fun extractDashStream(pageUrl: String, html: String): StreamUrl? {
val pageId = pageUrl.toHttpUrlOrNull()?.queryParameter("id") ?: return null
val idPattern = Regex(
"""(?s)\b${Regex.escape(pageId)}\s*:\s*\{\s*url:\s*["']([^"']+\.mpd[^"']*)["']\s*,\s*clearkey:\s*\{(.*?)\}\s*,?\s*\}"""
)
val match = idPattern.find(html) ?: return null
val mediaUrl = normalizeMediaUrl(match.groupValues[1]) ?: return null
val clearKeys = Regex("""['"]([0-9a-fA-F]+)['"]\s*:\s*['"]([0-9a-fA-F]+)['"]""")
.findAll(match.groupValues[2])
.associate { keyMatch -> keyMatch.groupValues[1] to keyMatch.groupValues[2] }
return StreamUrl(
url = mediaUrl,
referer = pageUrl,
streamType = StreamType.DASH,
clearKeys = clearKeys
)
}
private fun extractHlsStream(pageUrl: String, html: String): StreamUrl? {
val explicitPlaybackUrl = Regex(
"""playbackURL\s*=\s*["']([^"']+\.m3u8[^"']*)["']""",
setOf(RegexOption.IGNORE_CASE)
).find(html)?.groupValues?.getOrNull(1)
val directUrl = explicitPlaybackUrl
?: Regex(
"""https?://[^\s"'\\]+\.m3u8(?:\?[^\s"'\\]*)?""",
setOf(RegexOption.IGNORE_CASE)
).find(html)?.value
?: Regex(
"""["'](//[^"']+\.m3u8(?:\?[^"']*)?)["']""",
setOf(RegexOption.IGNORE_CASE)
).find(html)?.groupValues?.getOrNull(1)
?: extractObfuscatedPlaybackUrl(html)
val mediaUrl = normalizeMediaUrl(directUrl) ?: return null
return StreamUrl(
url = mediaUrl,
referer = pageUrl,
streamType = StreamType.HLS
)
}
private fun extractIframeUrl(pageUrl: String, html: String): String? {
val doc = Jsoup.parse(html, pageUrl)
val iframe = doc.selectFirst("iframe[src]") ?: return null
return normalizeUrl(iframe.attr("src"), pageUrl)
}
private fun extractObfuscatedPlaybackUrl(html: String): String? {
val entriesBlock = Regex("""(?s)ii\s*=\s*\[(.*?)]\s*;\s*ii\.sort""").find(html)?.groupValues?.getOrNull(1)
?: return null
val keyFunctions = Regex("""var\s+k\s*=\s*([A-Za-z_]\w*)\(\)\s*\+\s*([A-Za-z_]\w*)\(\)\s*;""")
.find(html)
?.destructured
?: return null
val firstFunctionValue = extractFunctionValue(html, keyFunctions.component1()) ?: return null
val secondFunctionValue = extractFunctionValue(html, keyFunctions.component2()) ?: return null
val baseOffset = firstFunctionValue + secondFunctionValue
val entries = Regex("""\[(\d+),"([^"]+)"]""")
.findAll(entriesBlock)
.map { match ->
match.groupValues[1].toInt() to match.groupValues[2]
}
.sortedBy { it.first }
.toList()
if (entries.isEmpty()) {
return null
}
val playbackUrl = buildString {
entries.forEach { (_, encodedToken) ->
val decoded = decodeBase64Token(encodedToken) ?: return@forEach
val digits = decoded.replace(Regex("""\D"""), "")
if (digits.isNotBlank()) {
append((digits.toInt() - baseOffset).toChar())
}
}
}
return normalizeMediaUrl(playbackUrl)
}
private fun extractFunctionValue(html: String, functionName: String): Int? {
val regex = Regex("""function\s+$functionName\(\)\s*\{\s*return\s+(\d+);\s*}""")
return regex.find(html)?.groupValues?.getOrNull(1)?.toIntOrNull()
}
private fun fetchHtml(url: String, referer: String?): String {
val request = buildRequest(url, referer)
val response = client.newCall(request).execute()
response.use {
if (!it.isSuccessful) {
throw IllegalStateException("HTTP ${it.code}")
}
return it.body?.string().orEmpty()
}
}
private fun buildRequest(url: String, referer: String?): Request {
val builder = Request.Builder()
.url(url)
.header("User-Agent", USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Cache-Control", "no-cache")
.header("Pragma", "no-cache")
val normalizedReferer = normalizeUrl(referer.orEmpty(), BASE_URL)
if (!normalizedReferer.isNullOrBlank()) {
builder.header("Referer", normalizedReferer)
}
return builder.build()
}
private fun decodeEmbeddedEventUrl(url: String): String? {
val httpUrl = url.toHttpUrlOrNull() ?: return null
if (!httpUrl.host.contains("futbollibretv.su")) {
return null
}
if (!httpUrl.encodedPath.contains("/eventos")) {
return null
}
val encoded = httpUrl.queryParameter("r") ?: httpUrl.queryParameter("embed") ?: return null
return decodeBase64Token(encoded)?.let { normalizeUrl(it, BASE_URL) }
}
private fun decodeBase64Token(value: String): String? {
val paddedValue = value.trim().let { raw ->
val missingPadding = (4 - raw.length % 4) % 4
raw + "=".repeat(missingPadding)
}
return try {
String(Base64.decode(paddedValue, Base64.DEFAULT), StandardCharsets.UTF_8)
} catch (_: IllegalArgumentException) {
null
}
}
private fun normalizeUrl(url: String, baseUrl: String): String? {
val trimmed = url.trim()
if (trimmed.isBlank() || trimmed == "#") {
return null
}
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
return trimmed
}
if (trimmed.startsWith("//")) {
return "https:$trimmed"
}
return baseUrl.toHttpUrlOrNull()?.resolve(trimmed)?.toString()
}
private fun normalizeMediaUrl(url: String?): String? {
if (url.isNullOrBlank()) {
return null
}
return when {
url.startsWith("http://") || url.startsWith("https://") -> url
url.startsWith("//") -> "https:$url"
else -> url
}
}
private fun isDirectHlsUrl(url: String): Boolean {
return url.contains(".m3u8", ignoreCase = true)
}
private fun isDirectDashUrl(url: String): Boolean {
return url.contains(".mpd", ignoreCase = true)
}
}

View File

@@ -0,0 +1,21 @@
package com.futbollibre.tv.ui.detail
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
import com.futbollibre.tv.R
class ChannelDetailsActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_details)
if (savedInstanceState == null) {
supportFragmentManager.beginTransaction()
.replace(R.id.details_fragment, ChannelDetailsFragment().apply {
arguments = intent.extras
})
.commit()
}
}
}

View File

@@ -0,0 +1,237 @@
package com.futbollibre.tv.ui.detail
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.leanback.app.DetailsSupportFragment
import androidx.leanback.widget.Action
import androidx.leanback.widget.ArrayObjectAdapter
import androidx.leanback.widget.DetailsOverviewRow
import androidx.leanback.widget.FullWidthDetailsOverviewRowPresenter
import androidx.leanback.widget.ListRow
import androidx.leanback.widget.ListRowPresenter
import androidx.leanback.widget.OnItemViewClickedListener
import androidx.leanback.widget.Presenter
import androidx.leanback.widget.PresenterSelector
import com.futbollibre.tv.PlayerActivity
import com.futbollibre.tv.R
import com.futbollibre.tv.model.Channel
import com.futbollibre.tv.model.StreamOption
class ChannelDetailsFragment : DetailsSupportFragment() {
private lateinit var channel: Channel
private val rowsAdapter = ArrayObjectAdapter(ChannelDetailsPresenterSelector())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
channel = arguments?.getParcelable("channel") ?: run {
activity?.finish()
return
}
setupDetailsRow()
setupListeners()
}
private fun setupDetailsRow() {
val actionsAdapter = ArrayObjectAdapter(ActionPresenter())
channel.streamUrls.forEachIndexed { index, option ->
actionsAdapter.add(
Action(
index.toLong(),
option.name,
option.quality.ifBlank { option.description.orEmpty() }
)
)
}
val detailsOverview = DetailsOverviewRow(channel).apply {
this.actionsAdapter = actionsAdapter
}
rowsAdapter.clear()
rowsAdapter.add(detailsOverview)
adapter = rowsAdapter
}
private fun setupListeners() {
onItemViewClickedListener = OnItemViewClickedListener { _, item, _, _ ->
if (item !is Action) {
return@OnItemViewClickedListener
}
val streamIndex = item.id.toInt()
val streamOption = channel.streamUrls.getOrNull(streamIndex) ?: return@OnItemViewClickedListener
playStream(streamOption)
}
}
private fun playStream(option: StreamOption) {
val intent = PlayerActivity.createIntent(
context = requireContext(),
streamUrl = option.url,
title = buildPlayerTitle(option),
channelId = channel.id
)
startActivity(intent)
}
private fun buildPlayerTitle(option: StreamOption): String {
return listOf(channel.name, option.name).joinToString(" · ")
}
private fun buildBodyText(channel: Channel): String {
if (channel.streamUrls.isEmpty()) {
return "Todavia no hay opciones de visualizacion para este evento."
}
val details = listOfNotNull(channel.category, channel.startTime)
.joinToString(" · ")
.ifBlank { channel.summary.orEmpty() }
return if (details.isBlank()) {
"Selecciona una opcion para ver este evento."
} else {
"$details\nSelecciona una opcion para ver este evento."
}
}
private class ActionPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val context = parent.context
val container = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
isFocusable = true
isFocusableInTouchMode = true
minimumWidth = 320
setPadding(36, 10, 36, 12)
background = ContextCompat.getDrawable(context, R.drawable.action_option_background)
elevation = 6f
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
val label1 = TextView(context).apply {
textSize = 15f
setTextColor(Color.WHITE)
gravity = Gravity.CENTER
includeFontPadding = false
maxLines = 1
}
label1.id = android.R.id.text1
val label2 = TextView(context).apply {
textSize = 10f
setTextColor(Color.GRAY)
gravity = Gravity.CENTER
includeFontPadding = false
maxLines = 1
}
label2.id = android.R.id.text2
container.addView(
label1,
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
)
container.addView(
label2,
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
topMargin = 2
}
)
updateFocusState(container, hasFocus = false)
container.setOnFocusChangeListener { view, hasFocus ->
updateFocusState(view, hasFocus)
}
return ViewHolder(container)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val action = item as Action
val container = viewHolder.view as LinearLayout
val label1 = container.findViewById<TextView>(android.R.id.text1)
val label2 = container.findViewById<TextView>(android.R.id.text2)
label1.text = action.label1
label2.text = action.label2
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit
private fun updateFocusState(view: View, hasFocus: Boolean) {
val container = view as LinearLayout
val label1 = container.findViewById<TextView>(android.R.id.text1)
val label2 = container.findViewById<TextView>(android.R.id.text2)
container.animate()
.scaleX(if (hasFocus) 1.08f else 1f)
.scaleY(if (hasFocus) 1.08f else 1f)
.setDuration(120)
.start()
label1.setTextColor(if (hasFocus) Color.WHITE else Color.WHITE)
label2.setTextColor(if (hasFocus) Color.WHITE else Color.GRAY)
}
}
private class ChannelDetailsPresenterSelector : PresenterSelector() {
private val detailsRowPresenter = FullWidthDetailsOverviewRowPresenter(
object : androidx.leanback.widget.AbstractDetailsDescriptionPresenter() {
override fun onBindDescription(viewHolder: ViewHolder, item: Any) {
val channel = item as Channel
viewHolder.title.text = channel.name
viewHolder.subtitle.text = channel.category
viewHolder.body.text = buildBodyText(channel)
}
private fun buildBodyText(channel: Channel): String {
if (channel.streamUrls.isEmpty()) {
return "Todavia no hay opciones de visualizacion para este evento."
}
val details = listOfNotNull(channel.startTime, channel.summary)
.filter { it.isNotBlank() }
.joinToString(" · ")
return if (details.isBlank()) {
"Selecciona una opcion para ver este evento."
} else {
"$details\nSelecciona una opcion para ver este evento."
}
}
}
)
private val rowPresenter = ListRowPresenter()
override fun getPresenter(item: Any): Presenter {
return when (item) {
is DetailsOverviewRow -> detailsRowPresenter
is ListRow -> rowPresenter
else -> throw IllegalArgumentException("Unsupported row type: ${item::class.java}")
}
}
override fun getPresenters(): Array<Presenter> {
return arrayOf(detailsRowPresenter, rowPresenter)
}
}
}

View File

@@ -0,0 +1,26 @@
package com.futbollibre.tv.util
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
object NetworkUtils {
fun isNetworkAvailable(context: Context): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} else {
@Suppress("DEPRECATION")
val networkInfo = connectivityManager.activeNetworkInfo
@Suppress("DEPRECATION")
return networkInfo?.isConnected == true
}
}
}

View File

@@ -0,0 +1,78 @@
package com.futbollibre.tv.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.futbollibre.tv.model.Channel
import com.futbollibre.tv.model.StreamUrl
import com.futbollibre.tv.repository.StreamRepository
import kotlinx.coroutines.launch
class MainViewModel : ViewModel() {
private val repository = StreamRepository()
private val _channels = MutableLiveData<List<Channel>>()
val channels: LiveData<List<Channel>> = _channels
private val _isLoading = MutableLiveData<Boolean>()
val isLoading: LiveData<Boolean> = _isLoading
private val _error = MutableLiveData<String?>()
val error: LiveData<String?> = _error
private val _streamUrl = MutableLiveData<StreamUrl?>()
val streamUrl: LiveData<StreamUrl?> = _streamUrl
init {
loadChannels()
}
fun loadChannels() {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
val result = repository.getChannels()
result.fold(
onSuccess = { channelList ->
_channels.value = channelList
_isLoading.value = false
},
onFailure = { exception ->
_error.value = exception.message ?: "Error desconocido"
_isLoading.value = false
}
)
}
}
fun extractStreamUrl(streamPageUrl: String) {
viewModelScope.launch {
_isLoading.value = true
val result = repository.extractM3u8Url(streamPageUrl)
result.fold(
onSuccess = { url ->
_streamUrl.value = url
_isLoading.value = false
},
onFailure = {
_error.value = "Error al obtener stream"
_isLoading.value = false
}
)
}
}
fun clearStreamUrl() {
_streamUrl.value = null
}
fun clearError() {
_error.value = null
}
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#2578B5" />
<stroke
android:width="3dp"
android:color="#FFFFFF" />
<corners android:radius="12dp" />
</shape>
</item>
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="#2578B5" />
<stroke
android:width="3dp"
android:color="#FFFFFF" />
<corners android:radius="12dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#1E1E1E" />
<stroke
android:width="1dp"
android:color="#3A3A3A" />
<corners android:radius="12dp" />
</shape>
</item>
</selector>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<!-- Fondo principal con gradiente azul -->
<path
android:fillColor="#0D47A1"
android:pathData="M0,0h320v180h-320z"/>
<!-- Efecto de gradiente diagonal -->
<path
android:fillColor="#1565C0"
android:pathData="M0,0L320,0L320,90Q160,120 0,90Z"/>
<!-- Efecto de luz en esquina superior derecha -->
<path
android:fillColor="#1976D2"
android:fillType="evenOdd"
android:pathData="M320,0L320,60Q280,40 240,0Z"/>
<!-- Patron decorativo de lineas diagonales -->
<group
android:alpha="0.1">
<path
android:fillColor="#FFFFFF"
android:pathData="M-20,180L20,180L100,0L60,0Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M60,180L100,180L180,0L140,0Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M140,180L180,180L260,0L220,0Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M220,180L260,180L340,0L300,0Z"/>
</group>
<!-- Circulos decorativos difuminados -->
<path
android:fillColor="#1E88E5"
android:fillAlpha="0.3"
android:pathData="M280,140m-40,0a40,40 0,1 1,80,0a40,40 0,1 1,-80,0"/>
<path
android:fillColor="#2196F3"
android:fillAlpha="0.2"
android:pathData="M40,40m-30,0a30,30 0,1 1,60,0a30,30 0,1 1,-60,0"/>
</vector>

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<!-- Balon de futbol estilizado a la izquierda -->
<group
android:translateX="70"
android:translateY="90">
<!-- Sombra del balon -->
<path
android:fillColor="#000000"
android:fillAlpha="0.2"
android:pathData="M5,-48A50,50 0 1,1 5,52A50,50 0 1,1 5,-48"/>
<!-- Circulo exterior del balon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M0,-50A50,50 0 1,1 0,50A50,50 0 1,1 0,-50"/>
<!-- Pentagono central -->
<path
android:fillColor="#0D47A1"
android:pathData="M0,-25L23,-8L14,20L-14,20L-23,-8Z"/>
<!-- Pentagonos superiores -->
<path
android:fillColor="#0D47A1"
android:pathData="M-37,-30L-13,-40L-7,-20L-27,-13Z"/>
<path
android:fillColor="#0D47A1"
android:pathData="M37,-30L13,-40L7,-20L27,-13Z"/>
<!-- Pentagonos inferiores -->
<path
android:fillColor="#0D47A1"
android:pathData="M-33,23L-13,13L-3,33L-23,40Z"/>
<path
android:fillColor="#0D47A1"
android:pathData="M33,23L13,13L3,33L23,40Z"/>
</group>
<!-- Texto "Futbol Libre" -->
<group
android:translateX="140"
android:translateY="75">
<!-- FUTBOL -->
<path
android:fillColor="#FFFFFF"
android:pathData="M-60,-15L-60,15L-52,15L-52,2L-40,2L-40,-4L-52,-4L-52,-9L-38,-9L-38,-15Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M-32,-15L-32,15L-24,15L-24,-15Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M-16,-15L-16,15L5,15L5,9L-8,9L-8,-15Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M12,-16C5,-16 0,-11 0,-4L0,4C0,11 5,16 12,16L18,16C25,16 30,11 30,4L30,-4C30,-11 25,-16 18,-16ZM11,-9L19,-9C21,-9 22,-7 22,-4L22,4C22,7 21,9 19,9L11,9C9,9 8,7 8,4L8,-4C8,-7 9,-9 11,-9Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M40,15L40,1L48,1L56,15L65,15L56,0C60,-2 62,-5 62,-10C62,-14 59,-15 54,-15L40,-15L40,15ZM48,-9L54,-9C56,-9 57,-8 57,-6C57,-4 56,-3 54,-3L48,-3Z"/>
<!-- LIBRE -->
<path
android:fillColor="#FFFFFF"
android:pathData="M-60,22L-60,52L-42,52L-42,46L-52,46L-52,22Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M-36,22L-36,52L-16,52L-16,46L-28,46L-28,40L-18,40L-18,34L-28,34L-28,28L-16,28L-16,22Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M-10,22L-10,52L2,52C10,52 14,47 14,40L14,34C14,27 10,22 2,22ZM-2,28L2,28C5,28 6,30 6,34L6,40C6,44 5,46 2,46L-2,46Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M20,22L20,52L42,52L42,46L28,46L28,40L38,40L38,34L28,34L28,28L42,28L42,22Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M48,22L48,52L56,52L56,40L64,52L74,52L64,38C68,36 70,33 70,28C70,24 68,22 62,22ZM56,28L62,28C64,28 65,29 65,31C65,33 64,34 62,34L56,34Z"/>
</group>
<!-- Icono de TV pequeno -->
<group
android:translateX="280"
android:translateY="140">
<!-- Pantalla de TV -->
<path
android:fillColor="#FFFFFF"
android:pathData="M-25,-15L25,-15A5,5 0 0,1 30,-10L30,15A5,5 0 0,1 25,20L-25,20A5,5 0 0,1 -30,15L-30,-10A5,5 0 0,1 -25,-15"/>
<!-- Pantalla interna azul -->
<path
android:fillColor="#1976D2"
android:pathData="M-22,-8L22,-8L22,14L-22,14Z"/>
<!-- Senal de onda (broadcast) -->
<path
android:fillColor="#FFFFFF"
android:pathData="M-12,-22Q-12,-28 -6,-28Q0,-28 0,-22"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M-18,-26Q-18,-35 -6,-35Q6,-35 6,-26"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M-24,-30Q-24,-42 -6,-42Q12,-42 12,-30"/>
<!-- Patas de la TV -->
<path
android:fillColor="#FFFFFF"
android:pathData="M-15,20L-10,20L-8,26L-17,26Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M15,20L10,20L8,26L17,26Z"/>
</group>
<!-- Texto "TV" pequeno -->
<group
android:translateX="280"
android:translateY="165">
<path
android:fillColor="#FFFFFF"
android:pathData="M-10,0L-4,0L4,-12L12,-12L12,0L16,0L16,-16L8,-16Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M20,-16L20,0L32,0L32,-4L24,-4L24,-16Z"/>
</group>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2578B5"/>
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#1E1E1E"/>
</shape>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#2D2D2D"/>
</shape>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Fondo azul gradiente simulado -->
<path
android:fillColor="#1565C0"
android:pathData="M0,0h108v108h-108z"/>
<!-- Circulo mas oscuro para efecto gradiente -->
<path
android:fillColor="#0D47A1"
android:pathData="M0,54Q27,27 54,27Q81,27 108,54L108,108L0,108Z"/>
</vector>

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Balon de futbol estilizado -->
<group
android:translateX="54"
android:translateY="54">
<!-- Circulo exterior del balon -->
<path
android:fillColor="#FFFFFF"
android:pathData="M0,-30A30,30 0 1,1 0,30A30,30 0 1,1 0,-30"/>
<!-- Pentagono central -->
<path
android:fillColor="#1565C0"
android:pathData="M0,-15L14,-5L9,12L-9,12L-14,-5Z"/>
<!-- Pentagonos superiores -->
<path
android:fillColor="#1565C0"
android:pathData="M-22,-18L-8,-24L-4,-12L-16,-8Z"/>
<path
android:fillColor="#1565C0"
android:pathData="M22,-18L8,-24L4,-12L16,-8Z"/>
<!-- Pentagonos inferiores -->
<path
android:fillColor="#1565C0"
android:pathData="M-20,14L-8,8L-2,20L-14,24Z"/>
<path
android:fillColor="#1565C0"
android:pathData="M20,14L8,8L2,20L14,24Z"/>
</group>
<!-- Icono de TV pequeno en la esquina -->
<group
android:translateX="78"
android:translateY="78">
<path
android:fillColor="#FFFFFF"
android:pathData="M-12,-8L12,-8A4,4 0 0,1 16,-4L16,8A4,4 0 0,1 12,12L-12,12A4,4 0 0,1 -16,8L-16,-4A4,4 0 0,1 -12,-8"/>
<!-- Pantalla -->
<path
android:fillColor="#1565C0"
android:pathData="M-10,-2L10,-2L10,8L-10,8Z"/>
<!-- Patas de la TV -->
<path
android:fillColor="#FFFFFF"
android:pathData="M-8,12L-6,12L-5,16L-9,16Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M8,12L6,12L5,16L9,16Z"/>
</group>
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/details_fragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
tools:context=".MainActivity">
<!-- Main Browse Fragment for Leanback UI -->
<fragment
android:id="@+id/main_browse_fragment"
android:name="com.futbollibre.tv.MainFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

View File

@@ -0,0 +1,138 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
tools:context=".PlayerActivity">
<!-- PlayerView de ExoPlayer (androidx.media3) -->
<androidx.media3.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black"
app:show_buffering="when_playing"
app:use_controller="true"
app:resize_mode="fit"
app:surface_type="surface_view" />
<!-- Loading Progress -->
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_gravity="center"
android:indeterminate="true"
android:indeterminateTint="@color/primary"
android:visibility="gone" />
<!-- Error Container -->
<LinearLayout
android:id="@+id/error_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone"
tools:visibility="visible">
<ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:contentDescription="@string/error_icon"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:tint="@color/text_primary" />
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:text="@string/stream_error"
android:textColor="@color/text_primary"
android:textSize="18sp" />
<Button
android:id="@+id/retry_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:backgroundTint="@color/primary"
android:text="@string/retry"
android:textColor="@color/text_primary" />
</LinearLayout>
<!-- Controls Overlay -->
<LinearLayout
android:id="@+id/controls_overlay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="top"
android:background="#80000000"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="16dp"
android:visibility="gone"
tools:visibility="visible">
<!-- Close Button -->
<ImageView
android:id="@+id/close_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/close"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="12dp"
android:src="@android:drawable/ic_menu_close_clear_cancel"
android:tint="@color/text_primary" />
<!-- Title -->
<TextView
android:id="@+id/title_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:textColor="@color/text_primary"
android:textSize="18sp"
android:textStyle="bold"
android:visibility="gone"
tools:text="TyC Sports"
tools:visibility="visible" />
<!-- Info (Position/Duration) -->
<TextView
android:id="@+id/info_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:textColor="@color/text_secondary"
android:textSize="14sp"
tools:text="00:00 / 00:00" />
<!-- Aspect Ratio Button -->
<ImageView
android:id="@+id/aspect_ratio_button"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/aspect_ratio"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="12dp"
android:src="@android:drawable/ic_menu_crop"
android:tint="@color/text_primary" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
android:background="?attr/colorPrimaryDark"
android:gravity="center_horizontal">
<!-- Header con titulo -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_title"
android:textSize="32sp"
android:textColor="@android:color/white"
android:textStyle="bold"
android:layout_marginBottom="32dp"
android:focusable="true"
android:focusableInTouchMode="true"/>
<!-- Logo de la app -->
<ImageView
android:layout_width="120dp"
android:layout_height="120dp"
android:src="@mipmap/ic_launcher"
android:layout_marginBottom="24dp"
android:contentDescription="@string/app_name"/>
<!-- Version de la app -->
<TextView
android:id="@+id/version_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textColor="@android:color/white"
android:layout_marginBottom="32dp"
android:gravity="center"/>
<!-- Separador -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#33FFFFFF"
android:layout_marginBottom="24dp"/>
<!-- ScrollView para creditos -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<TextView
android:id="@+id/credits_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp"
android:textColor="#CCFFFFFF"
android:lineSpacingExtra="8dp"
android:gravity="center"/>
</ScrollView>
<!-- Footer -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/copyright_notice"
android:textSize="12sp"
android:textColor="#66FFFFFF"
android:layout_marginTop="16dp"/>
</LinearLayout>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="@dimen/card_width"
android:layout_height="@dimen/card_height"
android:background="@color/card_background"
android:focusable="true"
android:focusableInTouchMode="true"
android:clickable="true">
<!-- ImageView para logo del canal -->
<ImageView
android:id="@+id/channel_logo"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="8dp"
android:contentDescription="@string/channel_logo"
android:scaleType="fitCenter"
app:layout_constraintBottom_toTopOf="@+id/channel_name"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@android:drawable/ic_menu_gallery" />
<!-- TextView para nombre del canal -->
<TextView
android:id="@+id/channel_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:ellipsize="end"
android:gravity="center"
android:maxLines="2"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="Canal de Ejemplo" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/card_background"
android:focusable="true"
android:focusableInTouchMode="true"
android:clickable="true"
android:padding="@dimen/vertical_margin">
<!-- ImageView para logo del canal -->
<ImageView
android:id="@+id/channel_logo"
android:layout_width="60dp"
android:layout_height="60dp"
android:contentDescription="@string/channel_logo"
android:scaleType="fitCenter"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:srcCompat="@android:drawable/ic_menu_gallery" />
<!-- TextView para nombre del canal -->
<TextView
android:id="@+id/channel_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/horizontal_margin"
android:layout_marginEnd="@dimen/horizontal_margin"
android:ellipsize="end"
android:maxLines="2"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/channel_logo"
app:layout_constraintTop_toTopOf="parent"
tools:text="Canal de Ejemplo" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/banner_background"/>
<foreground android:drawable="@drawable/banner_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#1E88E5</color>
<color name="primary_dark">#1565C0</color>
<color name="accent">#4CAF50</color>
<color name="background">#121212</color>
<color name="card_background">#1E1E1E</color>
<color name="card_selected_background">#2578B5</color>
<color name="text_primary">#FFFFFF</color>
<color name="text_secondary">#B0B0B0</color>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="horizontal_margin">48dp</dimen>
<dimen name="vertical_margin">24dp</dimen>
<dimen name="card_width">200dp</dimen>
<dimen name="card_height">150dp</dimen>
</resources>

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Fútbol Libre TV</string>
<string name="channels">Canales</string>
<string name="events">Agenda</string>
<string name="loading">Cargando...</string>
<string name="loading_events">Cargando agenda...</string>
<string name="error_loading">Error al cargar canales</string>
<string name="retry">Reintentar</string>
<string name="settings">Configuración</string>
<string name="about">Acerca de</string>
<string name="version">Versión %s</string>
<string name="no_internet">Sin conexión a internet</string>
<string name="stream_error">Error al reproducir stream</string>
<string name="error_player">Error al reproducir el video</string>
<string name="channel_logo">Logo del canal</string>
<string name="settings_title">Configuración</string>
<string name="copyright_notice">© 2024 Fútbol Libre TV - Código Abierto</string>
<string name="credits">Créditos</string>
<string name="version_info">Información de versión</string>
<string name="error_icon">Error</string>
<string name="close">Cerrar</string>
<string name="aspect_ratio">Cambiar proporción</string>
<string name="channel_name">Nombre del canal</string>
<string name="stream_options">Opciones de stream</string>
<string name="no_events">No se encontraron eventos</string>
<string name="rewind">Retroceder</string>
<string name="fast_forward">Avanzar</string>
<string name="play_pause">Reproducir/Pausar</string>
</resources>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme -->
<style name="Theme.FutbolLibreTV" parent="Theme.Leanback">
<item name="android:colorPrimary">@color/primary</item>
<item name="android:colorPrimaryDark">@color/primary_dark</item>
<item name="android:colorAccent">@color/accent</item>
<item name="android:windowBackground">@color/background</item>
</style>
<!-- Player theme (immersive) -->
<style name="Theme.FutbolLibreTV.Player" parent="Theme.Leanback">
<item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
</resources>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">futbollibretv.su</domain>
<domain includeSubdomains="true">latamvidz1.com</domain>
<domain includeSubdomains="true">la14hd.com</domain>
<domain includeSubdomains="true">streamtpcloud.com</domain>
<domain includeSubdomains="true">fubohd.com</domain>
</domain-config>
</network-security-config>