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

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.gradle/
build/
local.properties
*.iml
.idea/
app/build/
captures/
*.keystore
*.jks

54
README.md Normal file
View File

@@ -0,0 +1,54 @@
# Futbol Libre TV Android
Aplicacion para Android TV enfocada en una navegacion simple: abrir la agenda diaria, entrar a un evento, elegir una fuente de visualizacion y reproducirla directamente en TV.
## Highlights
- Agenda dinamica tomada en vivo desde `https://futbollibretv.su/agenda/`
- Navegacion pensada para control remoto y dispositivos Leanback
- Pantalla de detalle por evento con multiples opciones de reproduccion
- Soporte para HLS y DASH, incluyendo flujos protegidos con `ClearKey`
- Compatible con Chromecast con Google TV y otros equipos Android TV
## Stack
- Kotlin
- Android TV Leanback
- Media3 ExoPlayer
- OkHttp
- Jsoup
## Estructura
- `app/src/main/java/com/futbollibre/tv/MainFragment.kt`
Muestra la agenda actual y permite abrir cada evento.
- `app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsFragment.kt`
Lista las opciones disponibles para ver el evento seleccionado.
- `app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt`
Extrae agenda, fuentes, iframes y URLs finales de reproduccion.
- `app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt`
Configura la reproduccion en Media3 para HLS y DASH.
## Build
```bash
./gradlew assembleDebug
./gradlew assembleRelease
```
## APKs
- Debug: `app/build/outputs/apk/debug/app-debug.apk`
- Release: `app/build/outputs/apk/release/app-release.apk`
## Instalar por ADB
```bash
adb install -r app/build/outputs/apk/release/app-release.apk
```
## Notas
- El variant `release` se firma con la debug key local para generar un APK instalable sin depender de un keystore externo.
- La agenda cambia dia a dia y se consulta online en cada carga.
- El proyecto esta orientado a Android TV y no a telefonos.

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>

6
build.gradle.kts Normal file
View File

@@ -0,0 +1,6 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
id("org.jetbrains.kotlin.plugin.parcelize") version "1.9.22" apply false
}

38
gradle.properties Normal file
View File

@@ -0,0 +1,38 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
# Enable build cache for faster builds
org.gradle.caching=true
# Enable configuration caching (experimental)
# org.gradle.configuration-cache=true
# Default build flavor
android.defaults.buildfeatures.buildconfig=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

17
settings.gradle.kts Normal file
View File

@@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "FutbolLibreTV"
include(":app")