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