Initial release
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
local.properties
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
app/build/
|
||||||
|
captures/
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
54
README.md
Normal file
54
README.md
Normal 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
72
app/build.gradle.kts
Normal 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
11
app/proguard-rules.pro
vendored
Normal 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.** { *; }
|
||||||
57
app/src/main/AndroidManifest.xml
Normal file
57
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
120
app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt
Normal file
120
app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/src/main/java/com/futbollibre/tv/FutbolLibreApp.kt
Normal file
10
app/src/main/java/com/futbollibre/tv/FutbolLibreApp.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/src/main/java/com/futbollibre/tv/MainActivity.kt
Normal file
30
app/src/main/java/com/futbollibre/tv/MainActivity.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
237
app/src/main/java/com/futbollibre/tv/MainFragment.kt
Normal file
237
app/src/main/java/com/futbollibre/tv/MainFragment.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
600
app/src/main/java/com/futbollibre/tv/PlayerActivity.kt
Normal file
600
app/src/main/java/com/futbollibre/tv/PlayerActivity.kt
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
package com.futbollibre.tv
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.PlaybackException
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||||
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import com.futbollibre.tv.model.StreamType
|
||||||
|
import com.futbollibre.tv.model.StreamUrl
|
||||||
|
import com.futbollibre.tv.player.ExoPlayerManager
|
||||||
|
import com.futbollibre.tv.player.addStateListener
|
||||||
|
import com.futbollibre.tv.player.PlayerStateListener
|
||||||
|
import com.futbollibre.tv.repository.StreamRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity for playing HLS/m3u8 streams
|
||||||
|
* Supports Android TV remote control and various player controls
|
||||||
|
*/
|
||||||
|
class PlayerActivity : FragmentActivity(), PlayerStateListener {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PlayerActivity"
|
||||||
|
|
||||||
|
// Intent extras
|
||||||
|
const val EXTRA_STREAM_URL = "stream_url"
|
||||||
|
const val EXTRA_STREAM_TITLE = "stream_title"
|
||||||
|
const val EXTRA_REFERER = "referer"
|
||||||
|
const val EXTRA_CHANNEL_ID = "channel_id"
|
||||||
|
|
||||||
|
// Aspect ratio modes
|
||||||
|
private const val ASPECT_RATIO_FIT = 0
|
||||||
|
private const val ASPECT_RATIO_FILL = 1
|
||||||
|
private const val ASPECT_RATIO_ZOOM = 2
|
||||||
|
|
||||||
|
// UI hide delay
|
||||||
|
private const val UI_HIDE_DELAY_MS = 5000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an intent to start PlayerActivity
|
||||||
|
*/
|
||||||
|
fun createIntent(
|
||||||
|
context: Context,
|
||||||
|
streamUrl: String,
|
||||||
|
title: String? = null,
|
||||||
|
referer: String? = null,
|
||||||
|
channelId: String? = null
|
||||||
|
): Intent {
|
||||||
|
return Intent(context, PlayerActivity::class.java).apply {
|
||||||
|
putExtra(EXTRA_STREAM_URL, streamUrl)
|
||||||
|
putExtra(EXTRA_STREAM_TITLE, title)
|
||||||
|
putExtra(EXTRA_REFERER, referer)
|
||||||
|
putExtra(EXTRA_CHANNEL_ID, channelId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Views
|
||||||
|
private lateinit var playerView: PlayerView
|
||||||
|
private lateinit var progressBar: ProgressBar
|
||||||
|
private lateinit var errorContainer: View
|
||||||
|
private lateinit var errorMessage: TextView
|
||||||
|
private lateinit var controlsOverlay: View
|
||||||
|
private lateinit var titleTextView: TextView
|
||||||
|
private lateinit var infoTextView: TextView
|
||||||
|
private lateinit var aspectRatioButton: ImageView
|
||||||
|
|
||||||
|
// Player components
|
||||||
|
private var player: ExoPlayer? = null
|
||||||
|
private val playerManager = ExoPlayerManager.getInstance()
|
||||||
|
private lateinit var trackSelector: DefaultTrackSelector
|
||||||
|
|
||||||
|
// State
|
||||||
|
private var currentAspectRatioMode = ASPECT_RATIO_FIT
|
||||||
|
private var streamUrl: String? = null
|
||||||
|
private var streamTitle: String? = null
|
||||||
|
private var referer: String? = null
|
||||||
|
private var hasError = false
|
||||||
|
private var uiHideJob: Job? = null
|
||||||
|
private var isControlsVisible = false
|
||||||
|
|
||||||
|
// Repository for extracting streams
|
||||||
|
private val streamRepository = StreamRepository()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_player)
|
||||||
|
|
||||||
|
// Keep screen on during playback
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
|
||||||
|
// Hide system UI for immersive experience
|
||||||
|
hideSystemUI()
|
||||||
|
|
||||||
|
// Get intent extras
|
||||||
|
parseIntent(intent)
|
||||||
|
|
||||||
|
// Initialize views
|
||||||
|
initViews()
|
||||||
|
|
||||||
|
// Initialize player
|
||||||
|
initializePlayer()
|
||||||
|
|
||||||
|
// Load stream
|
||||||
|
loadStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initViews() {
|
||||||
|
playerView = findViewById(R.id.player_view)
|
||||||
|
progressBar = findViewById(R.id.progress_bar)
|
||||||
|
errorContainer = findViewById(R.id.error_container)
|
||||||
|
errorMessage = findViewById(R.id.error_message)
|
||||||
|
controlsOverlay = findViewById(R.id.controls_overlay)
|
||||||
|
titleTextView = findViewById(R.id.title_text)
|
||||||
|
infoTextView = findViewById(R.id.info_text)
|
||||||
|
aspectRatioButton = findViewById(R.id.aspect_ratio_button)
|
||||||
|
|
||||||
|
// Set up aspect ratio button
|
||||||
|
aspectRatioButton.setOnClickListener {
|
||||||
|
cycleAspectRatio()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up retry button
|
||||||
|
findViewById<View>(R.id.retry_button).setOnClickListener {
|
||||||
|
retryPlayback()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up close button
|
||||||
|
findViewById<View>(R.id.close_button).setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure PlayerView for TV
|
||||||
|
playerView.apply {
|
||||||
|
// Use controller for TV navigation
|
||||||
|
controllerShowTimeoutMs = UI_HIDE_DELAY_MS.toInt()
|
||||||
|
controllerHideOnTouch = true
|
||||||
|
useController = true
|
||||||
|
setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING)
|
||||||
|
// Keep screen on during playback
|
||||||
|
keepScreenOn = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show title if available
|
||||||
|
streamTitle?.let {
|
||||||
|
titleTextView.text = it
|
||||||
|
titleTextView.isVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseIntent(intent: Intent) {
|
||||||
|
streamUrl = intent.getStringExtra(EXTRA_STREAM_URL)
|
||||||
|
streamTitle = intent.getStringExtra(EXTRA_STREAM_TITLE)
|
||||||
|
referer = intent.getStringExtra(EXTRA_REFERER)
|
||||||
|
|
||||||
|
Log.d(TAG, "Stream URL: $streamUrl")
|
||||||
|
Log.d(TAG, "Stream Title: $streamTitle")
|
||||||
|
Log.d(TAG, "Referer: $referer")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializePlayer() {
|
||||||
|
Log.d(TAG, "Initializing ExoPlayer")
|
||||||
|
|
||||||
|
// Create track selector for subtitle/audio track selection
|
||||||
|
trackSelector = DefaultTrackSelector(this)
|
||||||
|
|
||||||
|
// Create player
|
||||||
|
player = playerManager.createPlayer(this, trackSelector)
|
||||||
|
|
||||||
|
// Add state listener
|
||||||
|
player?.addStateListener(this)
|
||||||
|
|
||||||
|
// Attach to view
|
||||||
|
playerView.player = player
|
||||||
|
|
||||||
|
Log.d(TAG, "ExoPlayer initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadStream() {
|
||||||
|
val url = streamUrl
|
||||||
|
|
||||||
|
if (url.isNullOrBlank()) {
|
||||||
|
showError("URL de stream no valida")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading()
|
||||||
|
hasError = false
|
||||||
|
|
||||||
|
when {
|
||||||
|
url.contains(".m3u8", ignoreCase = true) -> {
|
||||||
|
playResolvedStream(
|
||||||
|
StreamUrl(
|
||||||
|
url = url,
|
||||||
|
referer = referer,
|
||||||
|
streamType = StreamType.HLS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
url.contains(".mpd", ignoreCase = true) -> {
|
||||||
|
playResolvedStream(
|
||||||
|
StreamUrl(
|
||||||
|
url = url,
|
||||||
|
referer = referer,
|
||||||
|
streamType = StreamType.DASH
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
extractAndPlayUrl(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playResolvedStream(streamUrl: StreamUrl) {
|
||||||
|
Log.d(TAG, "Playing ${streamUrl.streamType} URL: ${streamUrl.url}")
|
||||||
|
playerManager.prepareStream(player!!, streamUrl, this@PlayerActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractAndPlayUrl(pageUrl: String) {
|
||||||
|
Log.d(TAG, "Extracting stream from page: $pageUrl")
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val result = streamRepository.extractStreamUrl(pageUrl)
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { resolvedStream ->
|
||||||
|
Log.d(TAG, "Successfully resolved stream: ${resolvedStream.url}")
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
playResolvedStream(resolvedStream)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { error ->
|
||||||
|
Log.e(TAG, "Failed to resolve stream", error)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
showError("No se pudo obtener el stream: ${error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLoading() {
|
||||||
|
progressBar.isVisible = true
|
||||||
|
errorContainer.isVisible = false
|
||||||
|
controlsOverlay.isVisible = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showError(message: String) {
|
||||||
|
hasError = true
|
||||||
|
progressBar.isVisible = false
|
||||||
|
errorContainer.isVisible = true
|
||||||
|
errorMessage.text = message
|
||||||
|
controlsOverlay.isVisible = true
|
||||||
|
|
||||||
|
Log.e(TAG, "Player error: $message")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideError() {
|
||||||
|
errorContainer.isVisible = false
|
||||||
|
hasError = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun retryPlayback() {
|
||||||
|
hideError()
|
||||||
|
loadStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cycleAspectRatio() {
|
||||||
|
currentAspectRatioMode = (currentAspectRatioMode + 1) % 3
|
||||||
|
|
||||||
|
when (currentAspectRatioMode) {
|
||||||
|
ASPECT_RATIO_FIT -> {
|
||||||
|
playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
showToast("Ajustar a pantalla")
|
||||||
|
}
|
||||||
|
ASPECT_RATIO_FILL -> {
|
||||||
|
playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||||
|
showToast("Llenar pantalla")
|
||||||
|
}
|
||||||
|
ASPECT_RATIO_ZOOM -> {
|
||||||
|
playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||||
|
showToast("Zoom")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleUiHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showToast(message: String) {
|
||||||
|
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayerStateListener implementation
|
||||||
|
override fun onLoadingChanged(isLoading: Boolean) {
|
||||||
|
Log.d(TAG, "Loading changed: $isLoading")
|
||||||
|
progressBar.isVisible = isLoading
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
|
||||||
|
val stateString = when (playbackState) {
|
||||||
|
Player.STATE_IDLE -> "IDLE"
|
||||||
|
Player.STATE_BUFFERING -> "BUFFERING"
|
||||||
|
Player.STATE_READY -> "READY"
|
||||||
|
Player.STATE_ENDED -> "ENDED"
|
||||||
|
else -> "UNKNOWN"
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Player state: $stateString, playWhenReady: $playWhenReady")
|
||||||
|
|
||||||
|
when (playbackState) {
|
||||||
|
Player.STATE_READY -> {
|
||||||
|
progressBar.isVisible = false
|
||||||
|
hideError()
|
||||||
|
showControls()
|
||||||
|
updateInfo()
|
||||||
|
}
|
||||||
|
Player.STATE_ENDED -> {
|
||||||
|
showToast("Reproduccion finalizada")
|
||||||
|
}
|
||||||
|
Player.STATE_BUFFERING -> {
|
||||||
|
progressBar.isVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
Log.e(TAG, "Player error", error)
|
||||||
|
|
||||||
|
val errorMessage = when (error.errorCode) {
|
||||||
|
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> "Error de conexion a internet"
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||||
|
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> "Error al procesar el stream"
|
||||||
|
PlaybackException.ERROR_CODE_IO_NO_PERMISSION -> "Sin permisos para acceder al stream"
|
||||||
|
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> "Error HTTP: ${error.message}"
|
||||||
|
PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> "Stream en vivo no disponible"
|
||||||
|
else -> "Error de reproduccion: ${error.message}"
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTracksChanged(hasVideo: Boolean, hasAudio: Boolean) {
|
||||||
|
Log.d(TAG, "Tracks changed - Video: $hasVideo, Audio: $hasAudio")
|
||||||
|
|
||||||
|
if (!hasVideo && !hasAudio) {
|
||||||
|
showError("No se encontraron pistas de video/audio")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showControls() {
|
||||||
|
if (!isControlsVisible) {
|
||||||
|
isControlsVisible = true
|
||||||
|
controlsOverlay.isVisible = true
|
||||||
|
playerView.showController()
|
||||||
|
}
|
||||||
|
scheduleUiHide()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideControls() {
|
||||||
|
if (isControlsVisible) {
|
||||||
|
isControlsVisible = false
|
||||||
|
controlsOverlay.isVisible = false
|
||||||
|
playerView.hideController()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleUiHide() {
|
||||||
|
uiHideJob?.cancel()
|
||||||
|
uiHideJob = lifecycleScope.launch {
|
||||||
|
delay(UI_HIDE_DELAY_MS)
|
||||||
|
if (!hasError) {
|
||||||
|
hideControls()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetTextI18n")
|
||||||
|
private fun updateInfo() {
|
||||||
|
player?.let { exoPlayer ->
|
||||||
|
val duration = exoPlayer.duration
|
||||||
|
val position = exoPlayer.currentPosition
|
||||||
|
|
||||||
|
val durationStr = formatTime(duration)
|
||||||
|
val positionStr = formatTime(position)
|
||||||
|
|
||||||
|
infoTextView.text = "$positionStr / $durationStr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(ms: Long): String {
|
||||||
|
if (ms == C.TIME_UNSET || ms < 0) return "--:--"
|
||||||
|
|
||||||
|
val totalSeconds = ms / 1000
|
||||||
|
val hours = totalSeconds / 3600
|
||||||
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
|
||||||
|
return if (hours > 0) {
|
||||||
|
String.format("%02d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
} else {
|
||||||
|
String.format("%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
private fun hideSystemUI() {
|
||||||
|
window.decorView.systemUiVisibility = (
|
||||||
|
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
|
||||||
|
or View.SYSTEM_UI_FLAG_FULLSCREEN
|
||||||
|
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
||||||
|
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
||||||
|
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
||||||
|
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle TV remote and keyboard input
|
||||||
|
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
|
||||||
|
when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||||
|
KeyEvent.KEYCODE_ENTER -> {
|
||||||
|
togglePlayPause()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||||
|
togglePlayPause()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||||
|
player?.play()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||||
|
player?.pause()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||||
|
player?.stop()
|
||||||
|
finish()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||||
|
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||||
|
seekBackward()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||||
|
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||||
|
seekForward()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> {
|
||||||
|
showControls()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> {
|
||||||
|
showControls()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_BACK -> {
|
||||||
|
if (isControlsVisible && !hasError) {
|
||||||
|
hideControls()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_INFO -> {
|
||||||
|
showControls()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_CAPTIONS -> {
|
||||||
|
toggleSubtitles()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_A -> {
|
||||||
|
cycleAspectRatio()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return super.onKeyDown(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun togglePlayPause() {
|
||||||
|
player?.let {
|
||||||
|
if (it.isPlaying) {
|
||||||
|
it.pause()
|
||||||
|
showToast("Pausado")
|
||||||
|
} else {
|
||||||
|
it.play()
|
||||||
|
showToast("Reproduciendo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seekForward() {
|
||||||
|
player?.let {
|
||||||
|
val newPosition = it.currentPosition + 10_000 // 10 seconds
|
||||||
|
val duration = it.duration
|
||||||
|
if (duration != C.TIME_UNSET && newPosition < duration) {
|
||||||
|
it.seekTo(newPosition)
|
||||||
|
showToast("+10 segundos")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seekBackward() {
|
||||||
|
player?.let {
|
||||||
|
val newPosition = it.currentPosition - 10_000 // 10 seconds
|
||||||
|
if (newPosition >= 0) {
|
||||||
|
it.seekTo(newPosition)
|
||||||
|
showToast("-10 segundos")
|
||||||
|
} else {
|
||||||
|
it.seekTo(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showControls()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleSubtitles() {
|
||||||
|
// TODO: Implement subtitle track selection
|
||||||
|
showToast("Subtitulos no disponibles")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
player?.playWhenReady = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||||
|
player?.playWhenReady = true
|
||||||
|
}
|
||||||
|
hideSystemUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||||
|
player?.playWhenReady = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
player?.playWhenReady = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
Log.d(TAG, "Destroying PlayerActivity")
|
||||||
|
|
||||||
|
// Cancel any pending jobs
|
||||||
|
uiHideJob?.cancel()
|
||||||
|
|
||||||
|
// Release player
|
||||||
|
player?.let {
|
||||||
|
playerManager.releasePlayer(it)
|
||||||
|
player = null
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "PlayerActivity destroyed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle new intents (for deep links)
|
||||||
|
override fun onNewIntent(intent: Intent?) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent?.let {
|
||||||
|
parseIntent(it)
|
||||||
|
loadStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/src/main/java/com/futbollibre/tv/SettingsActivity.kt
Normal file
62
app/src/main/java/com/futbollibre/tv/SettingsActivity.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/src/main/java/com/futbollibre/tv/model/Channel.kt
Normal file
49
app/src/main/java/com/futbollibre/tv/model/Channel.kt
Normal 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
|
||||||
220
app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt
Normal file
220
app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/src/main/java/com/futbollibre/tv/util/NetworkUtils.kt
Normal file
26
app/src/main/java/com/futbollibre/tv/util/NetworkUtils.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/src/main/res/drawable/action_option_background.xml
Normal file
30
app/src/main/res/drawable/action_option_background.xml
Normal 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>
|
||||||
52
app/src/main/res/drawable/banner_background.xml
Normal file
52
app/src/main/res/drawable/banner_background.xml
Normal 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>
|
||||||
135
app/src/main/res/drawable/banner_foreground.xml
Normal file
135
app/src/main/res/drawable/banner_foreground.xml
Normal 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>
|
||||||
5
app/src/main/res/drawable/card_selected_background.xml
Normal file
5
app/src/main/res/drawable/card_selected_background.xml
Normal 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>
|
||||||
5
app/src/main/res/drawable/default_background.xml
Normal file
5
app/src/main/res/drawable/default_background.xml
Normal 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>
|
||||||
5
app/src/main/res/drawable/ic_channel_default.xml
Normal file
5
app/src/main/res/drawable/ic_channel_default.xml
Normal 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>
|
||||||
18
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
18
app/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||||
65
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
65
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||||
5
app/src/main/res/layout/activity_details.xml
Normal file
5
app/src/main/res/layout/activity_details.xml
Normal 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" />
|
||||||
16
app/src/main/res/layout/activity_main.xml
Normal file
16
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||||
138
app/src/main/res/layout/activity_player.xml
Normal file
138
app/src/main/res/layout/activity_player.xml
Normal 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>
|
||||||
73
app/src/main/res/layout/activity_settings.xml
Normal file
73
app/src/main/res/layout/activity_settings.xml
Normal 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>
|
||||||
45
app/src/main/res/layout/channel_card.xml
Normal file
45
app/src/main/res/layout/channel_card.xml
Normal 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>
|
||||||
43
app/src/main/res/layout/item_channel.xml
Normal file
43
app/src/main/res/layout/item_channel.xml
Normal 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>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_banner.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_banner.xml
Normal 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>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
||||||
11
app/src/main/res/values/colors.xml
Normal file
11
app/src/main/res/values/colors.xml
Normal 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>
|
||||||
8
app/src/main/res/values/dimens.xml
Normal file
8
app/src/main/res/values/dimens.xml
Normal 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>
|
||||||
30
app/src/main/res/values/strings.xml
Normal file
30
app/src/main/res/values/strings.xml
Normal 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>
|
||||||
17
app/src/main/res/values/themes.xml
Normal file
17
app/src/main/res/values/themes.xml
Normal 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>
|
||||||
15
app/src/main/res/xml/network_security_config.xml
Normal file
15
app/src/main/res/xml/network_security_config.xml
Normal 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
6
build.gradle.kts
Normal 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
38
gradle.properties
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
gradlew
vendored
Executable 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
93
gradlew.bat
vendored
Normal 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
17
settings.gradle.kts
Normal 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")
|
||||||
Reference in New Issue
Block a user