From c1caef7a9660881581ba85fa5679ab7152bcb5e1 Mon Sep 17 00:00:00 2001 From: renato97 Date: Tue, 10 Mar 2026 16:28:04 -0300 Subject: [PATCH] Initial release --- .gitignore | 9 + README.md | 54 ++ app/build.gradle.kts | 72 +++ app/proguard-rules.pro | 11 + app/src/main/AndroidManifest.xml | 57 ++ .../futbollibre/tv/ChannelCardPresenter.kt | 120 ++++ .../java/com/futbollibre/tv/FutbolLibreApp.kt | 10 + .../java/com/futbollibre/tv/MainActivity.kt | 30 + .../java/com/futbollibre/tv/MainFragment.kt | 237 +++++++ .../java/com/futbollibre/tv/PlayerActivity.kt | 600 ++++++++++++++++++ .../com/futbollibre/tv/SettingsActivity.kt | 62 ++ .../java/com/futbollibre/tv/model/Channel.kt | 49 ++ .../futbollibre/tv/player/ExoPlayerManager.kt | 220 +++++++ .../tv/repository/StreamRepository.kt | 376 +++++++++++ .../tv/ui/detail/ChannelDetailsActivity.kt | 21 + .../tv/ui/detail/ChannelDetailsFragment.kt | 237 +++++++ .../com/futbollibre/tv/util/NetworkUtils.kt | 26 + .../futbollibre/tv/viewmodel/MainViewModel.kt | 78 +++ .../res/drawable/action_option_background.xml | 30 + .../main/res/drawable/banner_background.xml | 52 ++ .../main/res/drawable/banner_foreground.xml | 135 ++++ .../res/drawable/card_selected_background.xml | 5 + .../main/res/drawable/default_background.xml | 5 + .../main/res/drawable/ic_channel_default.xml | 5 + .../res/drawable/ic_launcher_background.xml | 18 + .../res/drawable/ic_launcher_foreground.xml | 65 ++ app/src/main/res/layout/activity_details.xml | 5 + app/src/main/res/layout/activity_main.xml | 16 + app/src/main/res/layout/activity_player.xml | 138 ++++ app/src/main/res/layout/activity_settings.xml | 73 +++ app/src/main/res/layout/channel_card.xml | 45 ++ app/src/main/res/layout/item_channel.xml | 43 ++ .../main/res/mipmap-anydpi-v26/ic_banner.xml | 5 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + app/src/main/res/values/colors.xml | 11 + app/src/main/res/values/dimens.xml | 8 + app/src/main/res/values/strings.xml | 30 + app/src/main/res/values/themes.xml | 17 + .../main/res/xml/network_security_config.xml | 15 + build.gradle.kts | 6 + gradle.properties | 38 ++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++++++ gradlew.bat | 93 +++ settings.gradle.kts | 17 + 46 files changed, 3404 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt create mode 100644 app/src/main/java/com/futbollibre/tv/FutbolLibreApp.kt create mode 100644 app/src/main/java/com/futbollibre/tv/MainActivity.kt create mode 100644 app/src/main/java/com/futbollibre/tv/MainFragment.kt create mode 100644 app/src/main/java/com/futbollibre/tv/PlayerActivity.kt create mode 100644 app/src/main/java/com/futbollibre/tv/SettingsActivity.kt create mode 100644 app/src/main/java/com/futbollibre/tv/model/Channel.kt create mode 100644 app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt create mode 100644 app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt create mode 100644 app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsActivity.kt create mode 100644 app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsFragment.kt create mode 100644 app/src/main/java/com/futbollibre/tv/util/NetworkUtils.kt create mode 100644 app/src/main/java/com/futbollibre/tv/viewmodel/MainViewModel.kt create mode 100644 app/src/main/res/drawable/action_option_background.xml create mode 100644 app/src/main/res/drawable/banner_background.xml create mode 100644 app/src/main/res/drawable/banner_foreground.xml create mode 100644 app/src/main/res/drawable/card_selected_background.xml create mode 100644 app/src/main/res/drawable/default_background.xml create mode 100644 app/src/main/res/drawable/ic_channel_default.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/layout/activity_details.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/activity_player.xml create mode 100644 app/src/main/res/layout/activity_settings.xml create mode 100644 app/src/main/res/layout/channel_card.xml create mode 100644 app/src/main/res/layout/item_channel.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_banner.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/dimens.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3df0275 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +local.properties +*.iml +.idea/ +app/build/ +captures/ +*.keystore +*.jks diff --git a/README.md b/README.md new file mode 100644 index 0000000..b027c23 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..23bd129 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..018fcb2 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 ; +} + +# Keep Jsoup +-keep class org.jsoup.** { *; } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..32812ca --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt b/app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt new file mode 100644 index 0000000..ca4803e --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt @@ -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) + } +} diff --git a/app/src/main/java/com/futbollibre/tv/FutbolLibreApp.kt b/app/src/main/java/com/futbollibre/tv/FutbolLibreApp.kt new file mode 100644 index 0000000..ba01d86 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/FutbolLibreApp.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futbollibre/tv/MainActivity.kt b/app/src/main/java/com/futbollibre/tv/MainActivity.kt new file mode 100644 index 0000000..6d3c3c4 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/MainActivity.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futbollibre/tv/MainFragment.kt b/app/src/main/java/com/futbollibre/tv/MainFragment.kt new file mode 100644 index 0000000..2a7cbe2 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/MainFragment.kt @@ -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) { + 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 + } +} diff --git a/app/src/main/java/com/futbollibre/tv/PlayerActivity.kt b/app/src/main/java/com/futbollibre/tv/PlayerActivity.kt new file mode 100644 index 0000000..2345da2 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/PlayerActivity.kt @@ -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(R.id.retry_button).setOnClickListener { + retryPlayback() + } + + // Set up close button + findViewById(R.id.close_button).setOnClickListener { + finish() + } + + // Configure PlayerView for TV + playerView.apply { + // Use controller for TV navigation + controllerShowTimeoutMs = UI_HIDE_DELAY_MS.toInt() + controllerHideOnTouch = true + useController = true + setShowBuffering(PlayerView.SHOW_BUFFERING_WHEN_PLAYING) + // Keep screen on during playback + keepScreenOn = true + } + + // Show title if available + streamTitle?.let { + titleTextView.text = it + titleTextView.isVisible = true + } + } + + private fun parseIntent(intent: Intent) { + streamUrl = intent.getStringExtra(EXTRA_STREAM_URL) + streamTitle = intent.getStringExtra(EXTRA_STREAM_TITLE) + referer = intent.getStringExtra(EXTRA_REFERER) + + Log.d(TAG, "Stream URL: $streamUrl") + Log.d(TAG, "Stream Title: $streamTitle") + Log.d(TAG, "Referer: $referer") + } + + private fun initializePlayer() { + Log.d(TAG, "Initializing ExoPlayer") + + // Create track selector for subtitle/audio track selection + trackSelector = DefaultTrackSelector(this) + + // Create player + player = playerManager.createPlayer(this, trackSelector) + + // Add state listener + player?.addStateListener(this) + + // Attach to view + playerView.player = player + + Log.d(TAG, "ExoPlayer initialized") + } + + private fun loadStream() { + val url = streamUrl + + if (url.isNullOrBlank()) { + showError("URL de stream no valida") + return + } + + showLoading() + hasError = false + + when { + url.contains(".m3u8", ignoreCase = true) -> { + playResolvedStream( + StreamUrl( + url = url, + referer = referer, + streamType = StreamType.HLS + ) + ) + } + url.contains(".mpd", ignoreCase = true) -> { + playResolvedStream( + StreamUrl( + url = url, + referer = referer, + streamType = StreamType.DASH + ) + ) + } + else -> { + extractAndPlayUrl(url) + } + } + } + + private fun playResolvedStream(streamUrl: StreamUrl) { + Log.d(TAG, "Playing ${streamUrl.streamType} URL: ${streamUrl.url}") + playerManager.prepareStream(player!!, streamUrl, this@PlayerActivity) + } + + private fun extractAndPlayUrl(pageUrl: String) { + Log.d(TAG, "Extracting stream from page: $pageUrl") + + lifecycleScope.launch { + val result = streamRepository.extractStreamUrl(pageUrl) + + result.fold( + onSuccess = { resolvedStream -> + Log.d(TAG, "Successfully resolved stream: ${resolvedStream.url}") + withContext(Dispatchers.Main) { + playResolvedStream(resolvedStream) + } + }, + onFailure = { error -> + Log.e(TAG, "Failed to resolve stream", error) + withContext(Dispatchers.Main) { + showError("No se pudo obtener el stream: ${error.message}") + } + } + ) + } + } + + private fun showLoading() { + progressBar.isVisible = true + errorContainer.isVisible = false + controlsOverlay.isVisible = false + } + + private fun showError(message: String) { + hasError = true + progressBar.isVisible = false + errorContainer.isVisible = true + errorMessage.text = message + controlsOverlay.isVisible = true + + Log.e(TAG, "Player error: $message") + } + + private fun hideError() { + errorContainer.isVisible = false + hasError = false + } + + private fun retryPlayback() { + hideError() + loadStream() + } + + private fun cycleAspectRatio() { + currentAspectRatioMode = (currentAspectRatioMode + 1) % 3 + + when (currentAspectRatioMode) { + ASPECT_RATIO_FIT -> { + playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + showToast("Ajustar a pantalla") + } + ASPECT_RATIO_FILL -> { + playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL + showToast("Llenar pantalla") + } + ASPECT_RATIO_ZOOM -> { + playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + showToast("Zoom") + } + } + + scheduleUiHide() + } + + private fun showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + // PlayerStateListener implementation + override fun onLoadingChanged(isLoading: Boolean) { + Log.d(TAG, "Loading changed: $isLoading") + progressBar.isVisible = isLoading + } + + override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { + val stateString = when (playbackState) { + Player.STATE_IDLE -> "IDLE" + Player.STATE_BUFFERING -> "BUFFERING" + Player.STATE_READY -> "READY" + Player.STATE_ENDED -> "ENDED" + else -> "UNKNOWN" + } + Log.d(TAG, "Player state: $stateString, playWhenReady: $playWhenReady") + + when (playbackState) { + Player.STATE_READY -> { + progressBar.isVisible = false + hideError() + showControls() + updateInfo() + } + Player.STATE_ENDED -> { + showToast("Reproduccion finalizada") + } + Player.STATE_BUFFERING -> { + progressBar.isVisible = true + } + } + } + + override fun onPlayerError(error: PlaybackException) { + Log.e(TAG, "Player error", error) + + val 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() + } + } +} diff --git a/app/src/main/java/com/futbollibre/tv/SettingsActivity.kt b/app/src/main/java/com/futbollibre/tv/SettingsActivity.kt new file mode 100644 index 0000000..fec2f38 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/SettingsActivity.kt @@ -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(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(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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futbollibre/tv/model/Channel.kt b/app/src/main/java/com/futbollibre/tv/model/Channel.kt new file mode 100644 index 0000000..b412906 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/model/Channel.kt @@ -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 = 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 = emptyMap() +) : Parcelable diff --git a/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt b/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt new file mode 100644 index 0000000..9ecfc68 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt @@ -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): 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): 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) + } + }) +} diff --git a/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt b/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt new file mode 100644 index 0000000..edbc781 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt @@ -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> = 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 = 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 { + return extractStreamUrl(streamPageUrl) + } + + private fun parseAgendaFromHtml(html: String): List { + val doc = Jsoup.parse(html, AGENDA_URL) + val events = mutableListOf() + + 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 { + 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) + } +} diff --git a/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsActivity.kt b/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsActivity.kt new file mode 100644 index 0000000..a6fdb50 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsActivity.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsFragment.kt b/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsFragment.kt new file mode 100644 index 0000000..253b8d8 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsFragment.kt @@ -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(android.R.id.text1) + val label2 = container.findViewById(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(android.R.id.text1) + val label2 = container.findViewById(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 { + return arrayOf(detailsRowPresenter, rowPresenter) + } + } +} diff --git a/app/src/main/java/com/futbollibre/tv/util/NetworkUtils.kt b/app/src/main/java/com/futbollibre/tv/util/NetworkUtils.kt new file mode 100644 index 0000000..cf7f638 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/util/NetworkUtils.kt @@ -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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/futbollibre/tv/viewmodel/MainViewModel.kt b/app/src/main/java/com/futbollibre/tv/viewmodel/MainViewModel.kt new file mode 100644 index 0000000..40d1ff1 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/viewmodel/MainViewModel.kt @@ -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>() + val channels: LiveData> = _channels + + private val _isLoading = MutableLiveData() + val isLoading: LiveData = _isLoading + + private val _error = MutableLiveData() + val error: LiveData = _error + + private val _streamUrl = MutableLiveData() + val streamUrl: LiveData = _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 + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/action_option_background.xml b/app/src/main/res/drawable/action_option_background.xml new file mode 100644 index 0000000..31405bf --- /dev/null +++ b/app/src/main/res/drawable/action_option_background.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/banner_background.xml b/app/src/main/res/drawable/banner_background.xml new file mode 100644 index 0000000..ae726fc --- /dev/null +++ b/app/src/main/res/drawable/banner_background.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/banner_foreground.xml b/app/src/main/res/drawable/banner_foreground.xml new file mode 100644 index 0000000..ad8875e --- /dev/null +++ b/app/src/main/res/drawable/banner_foreground.xml @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/card_selected_background.xml b/app/src/main/res/drawable/card_selected_background.xml new file mode 100644 index 0000000..9990b59 --- /dev/null +++ b/app/src/main/res/drawable/card_selected_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/default_background.xml b/app/src/main/res/drawable/default_background.xml new file mode 100644 index 0000000..c5d41d6 --- /dev/null +++ b/app/src/main/res/drawable/default_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_channel_default.xml b/app/src/main/res/drawable/ic_channel_default.xml new file mode 100644 index 0000000..2378c79 --- /dev/null +++ b/app/src/main/res/drawable/ic_channel_default.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ec86051 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,18 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..52fe103 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_details.xml b/app/src/main/res/layout/activity_details.xml new file mode 100644 index 0000000..de765f7 --- /dev/null +++ b/app/src/main/res/layout/activity_details.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..7e5a1b2 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml new file mode 100644 index 0000000..bdec811 --- /dev/null +++ b/app/src/main/res/layout/activity_player.xml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + +