diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 23bd129..278b74b 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -13,8 +13,8 @@ android {
applicationId = "com.futbollibre.tv"
minSdk = 21
targetSdk = 34
- versionCode = 1
- versionName = "1.0"
+ versionCode = 2
+ versionName = "1.1.0"
}
buildTypes {
@@ -46,6 +46,7 @@ dependencies {
// ExoPlayer for HLS streaming
implementation("androidx.media3:media3-exoplayer:1.2.1")
+ implementation("androidx.media3:media3-datasource-okhttp: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")
@@ -60,6 +61,7 @@ dependencies {
// OkHttp for HTTP requests
implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0")
// Jsoup for HTML parsing
implementation("org.jsoup:jsoup:1.17.2")
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 32812ca..f493e49 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -12,7 +12,7 @@
android:required="false" />
+ android:required="false" />
+
+
+
+
diff --git a/app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt b/app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt
index ca4803e..da3e5af 100644
--- a/app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt
+++ b/app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt
@@ -1,14 +1,20 @@
package com.futbollibre.tv
import android.content.Context
+import android.content.res.ColorStateList
+import android.graphics.Color
import android.graphics.drawable.Drawable
+import android.view.LayoutInflater
+import android.view.View
import android.view.ViewGroup
+import android.widget.FrameLayout
+import android.widget.ImageView
+import android.widget.TextView
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
+import com.futbollibre.tv.util.LeagueArt
+import com.futbollibre.tv.util.StreamOptionMetadata
/**
* Presenter for displaying agenda items as cards in the Leanback UI.
@@ -19,102 +25,61 @@ class ChannelCardPresenter(
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)
- }
+ private val defaultCardImage: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_channel_default)
+ private val defaultCardBackground = ContextCompat.getDrawable(context, R.drawable.event_card_background)
+ private val focusedCardBackground = ContextCompat.getDrawable(context, R.drawable.event_card_background_focused)
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
- val cardView = ImageCardView(context).apply {
- cardType = ImageCardView.CARD_TYPE_INFO_UNDER
+ val cardView = LayoutInflater.from(context).inflate(R.layout.card_event, parent, false).apply {
isFocusable = true
isFocusableInTouchMode = true
- setMainImageDimensions(cardWidth, cardHeight)
-
- // Set background colors
- setBackgroundColor(defaultBackgroundColor)
+ layoutParams = ViewGroup.LayoutParams(cardWidth, cardHeight)
+ background = defaultCardBackground
+ clipToOutline = true
}
+ updateFocusState(cardView, hasFocus = false)
+ cardView.setOnFocusChangeListener { view, hasFocus -> updateFocusState(view, hasFocus) }
return ViewHolder(cardView)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val channel = item as Channel
- val cardView = viewHolder.view as ImageCardView
+ val root = viewHolder.view
+ val watermark = root.findViewById(R.id.card_watermark)
+ val title = root.findViewById(R.id.card_title)
+ val time = root.findViewById(R.id.card_time)
+ val languages = root.findViewById(R.id.card_languages)
+ val providers = root.findViewById(R.id.card_providers)
- cardView.titleText = channel.name
+ val leagueDrawable = ContextCompat.getDrawable(context, LeagueArt.logoResId(channel.category)) ?: defaultCardImage
+ watermark.setImageDrawable(leagueDrawable)
+ watermark.imageTintList = ColorStateList.valueOf(Color.WHITE)
- 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
+ title.text = channel.name
+ time.text = channel.startTime ?: "--:--"
+ providers.text = StreamOptionMetadata.providerSummary(channel.streamUrls)
+
+ val languageSummary = StreamOptionMetadata.languageSummary(channel.streamUrls)
+ languages.text = when {
+ !languageSummary.isNullOrBlank() -> languageSummary
+ channel.streamUrls.isEmpty() -> "Sin links"
+ else -> "${channel.streamUrls.size} opciones"
}
+ languages.visibility = View.VISIBLE
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
- val cardView = viewHolder.view as ImageCardView
- cardView.badgeImage = null
- cardView.mainImage = null
+ viewHolder.view.findViewById(R.id.card_watermark).setImageDrawable(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)
+ private fun updateFocusState(view: View, hasFocus: Boolean) {
+ val container = view as FrameLayout
+ container.background = if (hasFocus) focusedCardBackground else defaultCardBackground
+ container.animate()
+ .scaleX(if (hasFocus) 1.06f else 1f)
+ .scaleY(if (hasFocus) 1.06f else 1f)
+ .setDuration(120)
+ .start()
}
}
diff --git a/app/src/main/java/com/futbollibre/tv/MainFragment.kt b/app/src/main/java/com/futbollibre/tv/MainFragment.kt
index 2a7cbe2..eeacde3 100644
--- a/app/src/main/java/com/futbollibre/tv/MainFragment.kt
+++ b/app/src/main/java/com/futbollibre/tv/MainFragment.kt
@@ -23,6 +23,7 @@ import androidx.lifecycle.lifecycleScope
import com.futbollibre.tv.model.Channel
import com.futbollibre.tv.repository.StreamRepository
import com.futbollibre.tv.ui.detail.ChannelDetailsActivity
+import com.futbollibre.tv.util.LeagueArt
import kotlinx.coroutines.launch
/**
@@ -77,7 +78,7 @@ class MainFragment : BrowseSupportFragment() {
backgroundManager = BackgroundManager.getInstance(requireActivity())
backgroundManager.attach(requireActivity().window)
- defaultBackground = ContextCompat.getDrawable(requireContext(), R.drawable.default_background)
+ defaultBackground = ContextCompat.getDrawable(requireContext(), R.drawable.bg_agenda_default)
backgroundManager.drawable = defaultBackground
}
@@ -92,6 +93,12 @@ class MainFragment : BrowseSupportFragment() {
onItemViewSelectedListener = OnItemViewSelectedListener { _, item, _, _ ->
if (item is Channel) {
Log.d(TAG, "Event selected: ${item.name}")
+ backgroundManager.drawable = ContextCompat.getDrawable(
+ requireContext(),
+ LeagueArt.backgroundResId(item.category)
+ ) ?: defaultBackground
+ } else {
+ backgroundManager.drawable = defaultBackground
}
}
}
@@ -184,6 +191,10 @@ class MainFragment : BrowseSupportFragment() {
adapter = rowsAdapter
selectedPosition = 0
+ backgroundManager.drawable = ContextCompat.getDrawable(
+ requireContext(),
+ LeagueArt.backgroundResId(channels.firstOrNull()?.category)
+ ) ?: defaultBackground
}
private fun showError(message: String) {
diff --git a/app/src/main/java/com/futbollibre/tv/PlayerActivity.kt b/app/src/main/java/com/futbollibre/tv/PlayerActivity.kt
index 2345da2..d0ce8b4 100644
--- a/app/src/main/java/com/futbollibre/tv/PlayerActivity.kt
+++ b/app/src/main/java/com/futbollibre/tv/PlayerActivity.kt
@@ -348,6 +348,15 @@ class PlayerActivity : FragmentActivity(), PlayerStateListener {
override fun onPlayerError(error: PlaybackException) {
Log.e(TAG, "Player error", error)
+ val rootCauseMessage = buildString {
+ var current: Throwable? = error
+ while (current != null) {
+ if (isNotEmpty()) append(" | ")
+ append(current.message.orEmpty())
+ current = current.cause
+ }
+ }
+
val errorMessage = when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> "Error de conexion a internet"
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
@@ -355,9 +364,21 @@ class PlayerActivity : FragmentActivity(), PlayerStateListener {
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"
+ PlaybackException.ERROR_CODE_DRM_UNSPECIFIED,
+ PlaybackException.ERROR_CODE_DRM_SCHEME_UNSUPPORTED,
+ PlaybackException.ERROR_CODE_DRM_CONTENT_ERROR,
+ PlaybackException.ERROR_CODE_DRM_SYSTEM_ERROR -> "Este stream usa un cifrado DRM que tu Chromecast no soporta. Proba otra opcion."
else -> "Error de reproduccion: ${error.message}"
}
+ if (rootCauseMessage.contains("ERROR_DRM_CANNOT_HANDLE", ignoreCase = true) ||
+ rootCauseMessage.contains("selected encryption mode is not supported", ignoreCase = true) ||
+ rootCauseMessage.contains("MissingSchemeDataException", ignoreCase = true)
+ ) {
+ showError("Este stream usa un cifrado DRM que tu Chromecast no soporta. Proba otra opcion.")
+ return
+ }
+
showError(errorMessage)
}
diff --git a/app/src/main/java/com/futbollibre/tv/model/Channel.kt b/app/src/main/java/com/futbollibre/tv/model/Channel.kt
index b412906..6e68b69 100644
--- a/app/src/main/java/com/futbollibre/tv/model/Channel.kt
+++ b/app/src/main/java/com/futbollibre/tv/model/Channel.kt
@@ -25,7 +25,8 @@ data class StreamOption(
val name: String,
val url: String,
val quality: String = "",
- val description: String? = null
+ val description: String? = null,
+ val language: String? = null
) : Parcelable
/**
diff --git a/app/src/main/java/com/futbollibre/tv/player/ClearKeyHlsPlaylistParserFactory.kt b/app/src/main/java/com/futbollibre/tv/player/ClearKeyHlsPlaylistParserFactory.kt
new file mode 100644
index 0000000..88ec4ec
--- /dev/null
+++ b/app/src/main/java/com/futbollibre/tv/player/ClearKeyHlsPlaylistParserFactory.kt
@@ -0,0 +1,185 @@
+package com.futbollibre.tv.player
+
+import android.net.Uri
+import androidx.media3.common.C
+import androidx.media3.common.DrmInitData
+import androidx.media3.common.MimeTypes
+import androidx.media3.exoplayer.hls.playlist.DefaultHlsPlaylistParserFactory
+import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist
+import androidx.media3.exoplayer.hls.playlist.HlsMultivariantPlaylist
+import androidx.media3.exoplayer.hls.playlist.HlsPlaylist
+import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParserFactory
+import androidx.media3.exoplayer.upstream.ParsingLoadable
+import androidx.media3.extractor.mp4.PsshAtomUtil
+import java.io.InputStream
+import java.util.UUID
+
+class ClearKeyHlsPlaylistParserFactory(
+ keyIds: Collection
+) : HlsPlaylistParserFactory {
+
+ private val delegate = DefaultHlsPlaylistParserFactory()
+ private val clearKeySchemeData = buildClearKeySchemeData(keyIds)
+
+ override fun createPlaylistParser(): ParsingLoadable.Parser {
+ return wrap(delegate.createPlaylistParser())
+ }
+
+ override fun createPlaylistParser(
+ multivariantPlaylist: HlsMultivariantPlaylist,
+ previousMediaPlaylist: HlsMediaPlaylist?
+ ): ParsingLoadable.Parser {
+ return wrap(delegate.createPlaylistParser(multivariantPlaylist, previousMediaPlaylist))
+ }
+
+ private fun wrap(
+ parser: ParsingLoadable.Parser
+ ): ParsingLoadable.Parser {
+ return ParsingLoadable.Parser { uri: Uri, inputStream: InputStream ->
+ val playlist = parser.parse(uri, inputStream)
+ if (playlist !is HlsMediaPlaylist || clearKeySchemeData == null) {
+ playlist
+ } else {
+ playlist.rewriteForClearKey(clearKeySchemeData)
+ }
+ }
+ }
+
+ private fun buildClearKeySchemeData(keyIds: Collection): DrmInitData.SchemeData? {
+ val uuidList = keyIds.mapNotNull(::toUuid)
+ if (uuidList.isEmpty()) {
+ return null
+ }
+
+ val psshData = PsshAtomUtil.buildPsshAtom(C.COMMON_PSSH_UUID, uuidList.toTypedArray(), null)
+ return DrmInitData.SchemeData(C.CLEARKEY_UUID, MimeTypes.VIDEO_MP4, psshData)
+ }
+
+ private fun toUuid(hex: String): UUID? {
+ val normalized = hex.trim().lowercase()
+ if (!normalized.matches(Regex("[0-9a-f]{32}"))) {
+ return null
+ }
+
+ val formatted = buildString {
+ append(normalized.substring(0, 8))
+ append('-')
+ append(normalized.substring(8, 12))
+ append('-')
+ append(normalized.substring(12, 16))
+ append('-')
+ append(normalized.substring(16, 20))
+ append('-')
+ append(normalized.substring(20, 32))
+ }
+ return runCatching { UUID.fromString(formatted) }.getOrNull()
+ }
+
+ private fun HlsMediaPlaylist.rewriteForClearKey(
+ schemeData: DrmInitData.SchemeData
+ ): HlsMediaPlaylist {
+ val rewrittenSegments = segments.map { it.rewriteForClearKey(schemeData) }
+ val rewrittenTrailingParts = trailingParts.map { it.rewriteForClearKey(schemeData) }
+
+ return HlsMediaPlaylist(
+ playlistType,
+ baseUri,
+ tags,
+ startOffsetUs,
+ preciseStart,
+ startTimeUs,
+ hasDiscontinuitySequence,
+ discontinuitySequence,
+ mediaSequence,
+ version,
+ targetDurationUs,
+ partTargetDurationUs,
+ hasIndependentSegments,
+ hasEndTag,
+ hasProgramDateTime,
+ protectionSchemes.rewriteForClearKey(schemeData),
+ rewrittenSegments,
+ rewrittenTrailingParts,
+ serverControl,
+ renditionReports
+ )
+ }
+
+ private fun HlsMediaPlaylist.Segment.rewriteForClearKey(
+ schemeData: DrmInitData.SchemeData
+ ): HlsMediaPlaylist.Segment {
+ val rewrittenInitSegment = initializationSegment?.rewriteInitializationSegment(schemeData)
+ val rewrittenParts = parts.map { it.rewriteForClearKey(schemeData) }
+
+ return HlsMediaPlaylist.Segment(
+ url,
+ rewrittenInitSegment,
+ title,
+ durationUs,
+ relativeDiscontinuitySequence,
+ relativeStartTimeUs,
+ drmInitData.rewriteForClearKey(schemeData),
+ fullSegmentEncryptionKeyUri,
+ encryptionIV,
+ byteRangeOffset,
+ byteRangeLength,
+ hasGapTag,
+ rewrittenParts
+ )
+ }
+
+ private fun HlsMediaPlaylist.Segment.rewriteInitializationSegment(
+ schemeData: DrmInitData.SchemeData
+ ): HlsMediaPlaylist.Segment {
+ return HlsMediaPlaylist.Segment(
+ url,
+ initializationSegment,
+ title,
+ durationUs,
+ relativeDiscontinuitySequence,
+ relativeStartTimeUs,
+ drmInitData.rewriteForClearKey(schemeData),
+ fullSegmentEncryptionKeyUri,
+ encryptionIV,
+ byteRangeOffset,
+ byteRangeLength,
+ hasGapTag,
+ parts.map { it.rewriteForClearKey(schemeData) }
+ )
+ }
+
+ private fun HlsMediaPlaylist.Part.rewriteForClearKey(
+ schemeData: DrmInitData.SchemeData
+ ): HlsMediaPlaylist.Part {
+ return HlsMediaPlaylist.Part(
+ url,
+ initializationSegment?.rewriteInitializationSegment(schemeData),
+ durationUs,
+ relativeDiscontinuitySequence,
+ relativeStartTimeUs,
+ drmInitData.rewriteForClearKey(schemeData),
+ fullSegmentEncryptionKeyUri,
+ encryptionIV,
+ byteRangeOffset,
+ byteRangeLength,
+ hasGapTag,
+ isIndependent,
+ isPreload
+ )
+ }
+
+ private fun DrmInitData?.rewriteForClearKey(
+ schemeData: DrmInitData.SchemeData
+ ): DrmInitData? {
+ if (this == null) {
+ return DrmInitData(schemeData)
+ }
+
+ val hasWidevine = (0 until schemeDataCount).any { get(it).uuid == C.WIDEVINE_UUID }
+ if (!hasWidevine) {
+ return this
+ }
+
+ return DrmInitData(schemeType, schemeData)
+ }
+}
diff --git a/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt b/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt
index 9ecfc68..0da995e 100644
--- a/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt
+++ b/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt
@@ -10,16 +10,18 @@ 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.dash.DashMediaSource
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.hls.HlsMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector
import com.futbollibre.tv.model.StreamType
import com.futbollibre.tv.model.StreamUrl
+import com.futbollibre.tv.util.NetworkStack
/**
* Manager class for ExoPlayer instance.
@@ -49,11 +51,9 @@ class ExoPlayerManager private constructor() {
): 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 httpDataSourceFactory = NetworkStack.mediaHttpDataSourceFactory(
+ userAgent = USER_AGENT
+ )
val dataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
@@ -76,22 +76,18 @@ class ExoPlayerManager private constructor() {
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)
-
+ val requestProperties = mutableMapOf()
streamUrl.referer?.let { referer ->
Log.d(TAG, "Setting Referer header: $referer")
- httpDataSourceFactory.setDefaultRequestProperties(
- mapOf(
- "Referer" to referer,
- "Origin" to extractOrigin(referer)
- )
- )
+ requestProperties["Referer"] = referer
+ requestProperties["Origin"] = extractOrigin(referer)
}
+ val httpDataSourceFactory = NetworkStack.mediaHttpDataSourceFactory(
+ userAgent = USER_AGENT,
+ requestProperties = requestProperties
+ )
+
val dataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
val mediaItemBuilder = MediaItem.Builder()
@@ -103,14 +99,34 @@ class ExoPlayerManager private constructor() {
}
)
- if (streamUrl.streamType == StreamType.DASH && streamUrl.clearKeys.isNotEmpty()) {
+ if (streamUrl.clearKeys.isNotEmpty()) {
val drmSessionManager = buildClearKeyDrmSessionManager(streamUrl.clearKeys)
- mediaSourceFactory.setDrmSessionManagerProvider { drmSessionManager }
mediaItemBuilder.setDrmConfiguration(
MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID)
.setPlayClearContentWithoutKey(true)
.build()
)
+
+ val mediaSource = when (streamUrl.streamType) {
+ StreamType.HLS -> HlsMediaSource.Factory(dataSourceFactory)
+ .setPlaylistParserFactory(
+ ClearKeyHlsPlaylistParserFactory(streamUrl.clearKeys.keys)
+ )
+ .setDrmSessionManagerProvider { drmSessionManager }
+ .createMediaSource(mediaItemBuilder.build())
+
+ StreamType.DASH -> {
+ mediaSourceFactory.setDrmSessionManagerProvider { drmSessionManager }
+ DashMediaSource.Factory(dataSourceFactory)
+ .setDrmSessionManagerProvider { drmSessionManager }
+ .createMediaSource(mediaItemBuilder.build())
+ }
+ }
+
+ player.setMediaSource(mediaSource)
+ player.prepare()
+ Log.d(TAG, "Stream preparation started")
+ return
}
val mediaSource = mediaSourceFactory.createMediaSource(mediaItemBuilder.build())
diff --git a/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt b/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt
index edbc781..a5506b6 100644
--- a/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt
+++ b/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt
@@ -6,15 +6,16 @@ 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 com.futbollibre.tv.util.AgendaTimeFormatter
+import com.futbollibre.tv.util.NetworkStack
+import com.futbollibre.tv.util.StreamOptionMetadata
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 {
@@ -27,13 +28,7 @@ class StreamRepository {
"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()
+ private val client = NetworkStack.httpClient
/**
* Fetches the current agenda from the remote site.
@@ -80,7 +75,8 @@ class StreamRepository {
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 rawTime = headerLink.selectFirst("span.t")?.text()?.trim()
+ val time = AgendaTimeFormatter.format(rawTime)
val fullTitle = headerLink.ownText().trim()
if (fullTitle.isBlank()) {
return null
@@ -94,12 +90,14 @@ class StreamRepository {
val url = normalizeUrl(link.attr("href"), AGENDA_URL) ?: return@mapNotNull null
val quality = link.selectFirst("span")?.text()?.trim().orEmpty()
val label = link.ownText().trim()
+ val language = StreamOptionMetadata.inferLanguage(label, url)
StreamOption(
name = label.ifBlank { "Opcion" },
url = url,
quality = quality,
- description = if (quality.isBlank()) null else quality
+ description = if (quality.isBlank()) null else quality,
+ language = language
)
}
@@ -201,9 +199,7 @@ class StreamRepository {
)
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] }
+ val clearKeys = extractClearKeys(match.groupValues[2])
return StreamUrl(
url = mediaUrl,
@@ -231,14 +227,41 @@ class StreamRepository {
?: extractObfuscatedPlaybackUrl(html)
val mediaUrl = normalizeMediaUrl(directUrl) ?: return null
+ val clearKeys = extractClearKeys(html)
return StreamUrl(
url = mediaUrl,
referer = pageUrl,
- streamType = StreamType.HLS
+ streamType = StreamType.HLS,
+ clearKeys = clearKeys
)
}
+ private fun extractClearKeys(rawContent: String): Map {
+ val directPairs = Regex("""['"]([0-9a-fA-F]{16,})['"]\s*:\s*['"]([0-9a-fA-F]{16,})['"]""")
+ .findAll(rawContent)
+ .associate { match -> match.groupValues[1] to match.groupValues[2] }
+
+ if (directPairs.isNotEmpty()) {
+ return directPairs
+ }
+
+ val keyIdMatch = Regex("""['"]keyId['"]\s*:\s*['"]([0-9a-fA-F]{16,})['"]""")
+ .find(rawContent)
+ ?.groupValues
+ ?.getOrNull(1)
+ val keyMatch = Regex("""['"]key['"]\s*:\s*['"]([0-9a-fA-F]{16,})['"]""")
+ .find(rawContent)
+ ?.groupValues
+ ?.getOrNull(1)
+
+ return if (!keyIdMatch.isNullOrBlank() && !keyMatch.isNullOrBlank()) {
+ mapOf(keyIdMatch to keyMatch)
+ } else {
+ emptyMap()
+ }
+ }
+
private fun extractIframeUrl(pageUrl: String, html: String): String? {
val doc = Jsoup.parse(html, pageUrl)
val iframe = doc.selectFirst("iframe[src]") ?: return null
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
index a6fdb50..50ffd0c 100644
--- a/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsActivity.kt
+++ b/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsActivity.kt
@@ -3,11 +3,16 @@ package com.futbollibre.tv.ui.detail
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
import com.futbollibre.tv.R
+import com.futbollibre.tv.model.Channel
+import com.futbollibre.tv.util.LeagueArt
class ChannelDetailsActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ window.setBackgroundDrawableResource(
+ LeagueArt.backgroundResId(readChannel()?.category)
+ )
setContentView(R.layout.activity_details)
if (savedInstanceState == null) {
@@ -18,4 +23,13 @@ class ChannelDetailsActivity : FragmentActivity() {
.commit()
}
}
+
+ private fun readChannel(): Channel? {
+ return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra("channel", Channel::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra("channel")
+ }
+ }
}
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
index 253b8d8..271acf2 100644
--- a/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsFragment.kt
+++ b/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsFragment.kt
@@ -22,6 +22,7 @@ import com.futbollibre.tv.PlayerActivity
import com.futbollibre.tv.R
import com.futbollibre.tv.model.Channel
import com.futbollibre.tv.model.StreamOption
+import com.futbollibre.tv.util.StreamOptionMetadata
class ChannelDetailsFragment : DetailsSupportFragment() {
@@ -47,7 +48,7 @@ class ChannelDetailsFragment : DetailsSupportFragment() {
Action(
index.toLong(),
option.name,
- option.quality.ifBlank { option.description.orEmpty() }
+ buildOptionMeta(option)
)
)
}
@@ -84,7 +85,7 @@ class ChannelDetailsFragment : DetailsSupportFragment() {
}
private fun buildPlayerTitle(option: StreamOption): String {
- return listOf(channel.name, option.name).joinToString(" · ")
+ return listOfNotNull(channel.name, option.name, option.language).joinToString(" · ")
}
private fun buildBodyText(channel: Channel): String {
@@ -92,7 +93,8 @@ class ChannelDetailsFragment : DetailsSupportFragment() {
return "Todavia no hay opciones de visualizacion para este evento."
}
- val details = listOfNotNull(channel.category, channel.startTime)
+ val languageSummary = StreamOptionMetadata.languageSummary(channel.streamUrls)?.let { "Audio $it" }
+ val details = listOfNotNull(channel.category, channel.startTime, languageSummary)
.joinToString(" · ")
.ifBlank { channel.summary.orEmpty() }
@@ -103,6 +105,13 @@ class ChannelDetailsFragment : DetailsSupportFragment() {
}
}
+ private fun buildOptionMeta(option: StreamOption): String {
+ return listOfNotNull(
+ option.quality.takeIf { it.isNotBlank() },
+ option.language
+ ).joinToString(" · ")
+ }
+
private class ActionPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val context = parent.context
@@ -172,6 +181,7 @@ class ChannelDetailsFragment : DetailsSupportFragment() {
label1.text = action.label1
label2.text = action.label2
+ label2.visibility = if (action.label2.isNullOrBlank()) View.GONE else View.VISIBLE
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit
@@ -207,7 +217,11 @@ class ChannelDetailsFragment : DetailsSupportFragment() {
return "Todavia no hay opciones de visualizacion para este evento."
}
- val details = listOfNotNull(channel.startTime, channel.summary)
+ val languageSummary = StreamOptionMetadata.languageSummary(channel.streamUrls)?.let { "Audio $it" }
+ val details = listOfNotNull(
+ channel.startTime,
+ languageSummary
+ )
.filter { it.isNotBlank() }
.joinToString(" · ")
diff --git a/app/src/main/java/com/futbollibre/tv/util/AgendaTimeFormatter.kt b/app/src/main/java/com/futbollibre/tv/util/AgendaTimeFormatter.kt
new file mode 100644
index 0000000..a641f49
--- /dev/null
+++ b/app/src/main/java/com/futbollibre/tv/util/AgendaTimeFormatter.kt
@@ -0,0 +1,47 @@
+package com.futbollibre.tv.util
+
+import java.util.Locale
+import java.util.TimeZone
+
+object AgendaTimeFormatter {
+
+ private const val SOURCE_TIMEZONE_OFFSET_MINUTES = 60
+ private val amPmOffsets = setOf(600, 570, -300, -420, -480, -540, -600, -660)
+
+ fun format(rawTime: String?): String? {
+ if (rawTime.isNullOrBlank()) {
+ return rawTime
+ }
+
+ val parts = rawTime.split(":", limit = 2)
+ if (parts.size != 2) {
+ return rawTime
+ }
+
+ val hours = parts[0].toIntOrNull() ?: return rawTime
+ val minutes = parts[1].toIntOrNull() ?: return rawTime
+ val sourceMinutes = (hours * 60) + minutes
+ val deviceOffsetMinutes = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 60_000
+ val adjustedMinutes = ((sourceMinutes + deviceOffsetMinutes - SOURCE_TIMEZONE_OFFSET_MINUTES) % MINUTES_PER_DAY + MINUTES_PER_DAY) % MINUTES_PER_DAY
+ val adjustedHours = adjustedMinutes / 60
+ val adjustedRemainderMinutes = adjustedMinutes % 60
+
+ return if (deviceOffsetMinutes in amPmOffsets) {
+ formatAmPm(adjustedHours, adjustedRemainderMinutes)
+ } else {
+ String.format(Locale.US, "%02d:%02d", adjustedHours, adjustedRemainderMinutes)
+ }
+ }
+
+ private fun formatAmPm(hours: Int, minutes: Int): String {
+ val period = if (hours >= 12) "pm" else "am"
+ val displayHour = when {
+ hours == 0 -> 12
+ hours > 12 -> hours - 12
+ else -> hours
+ }
+ return String.format(Locale.US, "%d:%02d%s", displayHour, minutes, period)
+ }
+
+ private const val MINUTES_PER_DAY = 24 * 60
+}
diff --git a/app/src/main/java/com/futbollibre/tv/util/LeagueArt.kt b/app/src/main/java/com/futbollibre/tv/util/LeagueArt.kt
new file mode 100644
index 0000000..4060f3a
--- /dev/null
+++ b/app/src/main/java/com/futbollibre/tv/util/LeagueArt.kt
@@ -0,0 +1,69 @@
+package com.futbollibre.tv.util
+
+import androidx.annotation.DrawableRes
+import com.futbollibre.tv.R
+import java.text.Normalizer
+
+object LeagueArt {
+
+ @DrawableRes
+ fun logoResId(category: String?): Int {
+ return when (resolveKey(category)) {
+ LeagueKey.CHAMPIONS -> R.drawable.league_uefa_champions
+ LeagueKey.LIBERTADORES -> R.drawable.league_copa_libertadores
+ LeagueKey.LIGA_PROFESIONAL -> R.drawable.league_liga_profesional
+ LeagueKey.CONCACAF -> R.drawable.league_concacaf_champions
+ LeagueKey.BETPLAY -> R.drawable.league_liga_betplay
+ LeagueKey.BRASILEIRAO -> R.drawable.league_brasileirao
+ LeagueKey.COPA_ARGENTINA -> R.drawable.league_copa_argentina
+ LeagueKey.DEFAULT -> R.drawable.ic_channel_default
+ }
+ }
+
+ @DrawableRes
+ fun backgroundResId(category: String?): Int {
+ return when (resolveKey(category)) {
+ LeagueKey.CHAMPIONS -> R.drawable.bg_league_champions
+ LeagueKey.LIBERTADORES -> R.drawable.bg_league_libertadores
+ LeagueKey.LIGA_PROFESIONAL -> R.drawable.bg_league_liga_profesional
+ LeagueKey.CONCACAF -> R.drawable.bg_league_concacaf
+ LeagueKey.BETPLAY -> R.drawable.bg_league_betplay
+ LeagueKey.BRASILEIRAO -> R.drawable.bg_league_brasileirao
+ LeagueKey.COPA_ARGENTINA -> R.drawable.bg_league_copa_argentina
+ LeagueKey.DEFAULT -> R.drawable.bg_agenda_default
+ }
+ }
+
+ private fun resolveKey(category: String?): LeagueKey {
+ val normalized = normalize(category)
+ return when {
+ "champions league" in normalized -> LeagueKey.CHAMPIONS
+ "copa libertadores" in normalized -> LeagueKey.LIBERTADORES
+ "liga profesional" in normalized -> LeagueKey.LIGA_PROFESIONAL
+ "concacaf champions" in normalized -> LeagueKey.CONCACAF
+ "betplay" in normalized -> LeagueKey.BETPLAY
+ "brasileirao" in normalized -> LeagueKey.BRASILEIRAO
+ "copa argentina" in normalized -> LeagueKey.COPA_ARGENTINA
+ else -> LeagueKey.DEFAULT
+ }
+ }
+
+ private fun normalize(value: String?): String {
+ if (value.isNullOrBlank()) {
+ return ""
+ }
+ return Normalizer.normalize(value.lowercase(), Normalizer.Form.NFD)
+ .replace(Regex("\\p{Mn}+"), "")
+ }
+
+ private enum class LeagueKey {
+ CHAMPIONS,
+ LIBERTADORES,
+ LIGA_PROFESIONAL,
+ CONCACAF,
+ BETPLAY,
+ BRASILEIRAO,
+ COPA_ARGENTINA,
+ DEFAULT
+ }
+}
diff --git a/app/src/main/java/com/futbollibre/tv/util/NetworkStack.kt b/app/src/main/java/com/futbollibre/tv/util/NetworkStack.kt
new file mode 100644
index 0000000..51a3dd8
--- /dev/null
+++ b/app/src/main/java/com/futbollibre/tv/util/NetworkStack.kt
@@ -0,0 +1,49 @@
+package com.futbollibre.tv.util
+
+import androidx.media3.datasource.HttpDataSource
+import androidx.media3.datasource.okhttp.OkHttpDataSource
+import okhttp3.OkHttpClient
+import okhttp3.dnsoverhttps.DnsOverHttps
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import java.net.InetAddress
+import java.util.concurrent.TimeUnit
+
+object NetworkStack {
+
+ private const val TIMEOUT_SECONDS = 30L
+ private const val DNS_OVER_HTTPS_URL = "https://cloudflare-dns.com/dns-query"
+
+ private val bootstrapClient = OkHttpClient.Builder()
+ .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ .followRedirects(true)
+ .followSslRedirects(true)
+ .build()
+
+ private val cloudflareDns = DnsOverHttps.Builder()
+ .client(bootstrapClient)
+ .url(DNS_OVER_HTTPS_URL.toHttpUrl())
+ .bootstrapDnsHosts(
+ InetAddress.getByName("1.1.1.1"),
+ InetAddress.getByName("1.0.0.1"),
+ InetAddress.getByName("2606:4700:4700::1111"),
+ InetAddress.getByName("2606:4700:4700::1001")
+ )
+ .includeIPv6(true)
+ .resolvePrivateAddresses(false)
+ .build()
+
+ val httpClient: OkHttpClient = bootstrapClient.newBuilder()
+ .dns(cloudflareDns)
+ .build()
+
+ fun mediaHttpDataSourceFactory(
+ userAgent: String,
+ requestProperties: Map = emptyMap()
+ ): HttpDataSource.Factory {
+ return OkHttpDataSource.Factory(httpClient)
+ .setUserAgent(userAgent)
+ .setDefaultRequestProperties(requestProperties)
+ }
+}
diff --git a/app/src/main/java/com/futbollibre/tv/util/StreamOptionMetadata.kt b/app/src/main/java/com/futbollibre/tv/util/StreamOptionMetadata.kt
new file mode 100644
index 0000000..19014ce
--- /dev/null
+++ b/app/src/main/java/com/futbollibre/tv/util/StreamOptionMetadata.kt
@@ -0,0 +1,116 @@
+package com.futbollibre.tv.util
+
+import android.util.Base64
+import com.futbollibre.tv.model.StreamOption
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import java.nio.charset.StandardCharsets
+import java.text.Normalizer
+
+object StreamOptionMetadata {
+
+ fun inferLanguage(optionName: String, optionUrl: String): String? {
+ val decodedUrl = decodeEmbeddedUrl(optionUrl).orEmpty()
+ val combined = normalize("$optionName $optionUrl $decodedUrl")
+
+ return when {
+ combined.contains("tudn") -> "ESP"
+ combined.contains("virgin") -> "ENG"
+ combined.contains("paramount") -> "ENG"
+ combined.contains("tnt_1_gb") || combined.contains("tnt_2_gb") || combined.contains("_gb") -> "ENG"
+ combined.contains("latamvidz") || combined.contains("la14hd") -> "ESP"
+ combined.contains("foxsports") || combined.contains("fox sports") -> "ESP"
+ combined.contains("espn") -> "ESP"
+ combined.contains("disney") -> "ESP"
+ combined.contains("fanatiz") -> "ESP"
+ combined.contains("tyc") -> "ESP"
+ combined.contains("telefe") -> "ESP"
+ combined.contains("directv") || combined.contains("dgo") -> "ESP"
+ combined.contains("sportv") || combined.contains("premiere") || combined.contains("globo") -> "POR"
+ combined.contains("esvideofy") && combined.contains("max") -> "ENG"
+ combined.contains("max1") || combined.contains("max2") || combined.contains("max3") || combined.contains("max4") -> "ESP"
+ else -> null
+ }
+ }
+
+ fun providerSummary(options: List): String {
+ if (options.isEmpty()) {
+ return "Sin opciones por ahora"
+ }
+
+ val providers = options
+ .map { cleanupProviderName(it.name) }
+ .filter { it.isNotBlank() }
+ .distinct()
+
+ if (providers.isEmpty()) {
+ return "${options.size} opciones"
+ }
+
+ val visibleProviders = providers.take(2)
+ val remainingCount = providers.size - visibleProviders.size
+
+ return buildString {
+ append(visibleProviders.joinToString(" · "))
+ if (remainingCount > 0) {
+ append(" · +")
+ append(remainingCount)
+ }
+ }
+ }
+
+ fun languageSummary(options: List): String? {
+ val languages = options
+ .mapNotNull { it.language }
+ .distinct()
+ .sortedBy { languageOrder(it) }
+
+ if (languages.isEmpty()) {
+ return null
+ }
+
+ return languages.joinToString(" · ")
+ }
+
+ private fun cleanupProviderName(value: String): String {
+ return value
+ .replace(Regex("""\s*\(.*?\)"""), "")
+ .replace(Regex("""\s*\|\s*OP\s*\d+""", RegexOption.IGNORE_CASE), "")
+ .trim()
+ }
+
+ private fun decodeEmbeddedUrl(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
+ val paddedValue = encoded.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 normalize(value: String): String {
+ return Normalizer.normalize(value.lowercase(), Normalizer.Form.NFD)
+ .replace(Regex("\\p{Mn}+"), "")
+ }
+
+ private fun languageOrder(language: String): Int {
+ return when (language) {
+ "ESP" -> 0
+ "ENG" -> 1
+ "POR" -> 2
+ else -> 9
+ }
+ }
+}
diff --git a/app/src/main/res/drawable-nodpi/league_brasileirao.png b/app/src/main/res/drawable-nodpi/league_brasileirao.png
new file mode 100644
index 0000000..af02b12
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_brasileirao.png differ
diff --git a/app/src/main/res/drawable-nodpi/league_concacaf_champions.png b/app/src/main/res/drawable-nodpi/league_concacaf_champions.png
new file mode 100644
index 0000000..179ac12
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_concacaf_champions.png differ
diff --git a/app/src/main/res/drawable-nodpi/league_copa_argentina.png b/app/src/main/res/drawable-nodpi/league_copa_argentina.png
new file mode 100644
index 0000000..7aa7ab3
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_copa_argentina.png differ
diff --git a/app/src/main/res/drawable-nodpi/league_copa_libertadores.png b/app/src/main/res/drawable-nodpi/league_copa_libertadores.png
new file mode 100644
index 0000000..2e9b0ad
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_copa_libertadores.png differ
diff --git a/app/src/main/res/drawable-nodpi/league_liga_betplay.png b/app/src/main/res/drawable-nodpi/league_liga_betplay.png
new file mode 100644
index 0000000..d793648
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_liga_betplay.png differ
diff --git a/app/src/main/res/drawable-nodpi/league_liga_profesional.png b/app/src/main/res/drawable-nodpi/league_liga_profesional.png
new file mode 100644
index 0000000..af9f800
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_liga_profesional.png differ
diff --git a/app/src/main/res/drawable-nodpi/league_uefa_champions.png b/app/src/main/res/drawable-nodpi/league_uefa_champions.png
new file mode 100644
index 0000000..85cfb7c
Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_uefa_champions.png differ
diff --git a/app/src/main/res/drawable/bg_agenda_default.xml b/app/src/main/res/drawable/bg_agenda_default.xml
new file mode 100644
index 0000000..fd8f7ee
--- /dev/null
+++ b/app/src/main/res/drawable/bg_agenda_default.xml
@@ -0,0 +1,19 @@
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_league_betplay.xml b/app/src/main/res/drawable/bg_league_betplay.xml
new file mode 100644
index 0000000..263d604
--- /dev/null
+++ b/app/src/main/res/drawable/bg_league_betplay.xml
@@ -0,0 +1,27 @@
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_league_brasileirao.xml b/app/src/main/res/drawable/bg_league_brasileirao.xml
new file mode 100644
index 0000000..a7cb536
--- /dev/null
+++ b/app/src/main/res/drawable/bg_league_brasileirao.xml
@@ -0,0 +1,27 @@
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_league_champions.xml b/app/src/main/res/drawable/bg_league_champions.xml
new file mode 100644
index 0000000..8c362fb
--- /dev/null
+++ b/app/src/main/res/drawable/bg_league_champions.xml
@@ -0,0 +1,27 @@
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_league_concacaf.xml b/app/src/main/res/drawable/bg_league_concacaf.xml
new file mode 100644
index 0000000..1cd124a
--- /dev/null
+++ b/app/src/main/res/drawable/bg_league_concacaf.xml
@@ -0,0 +1,27 @@
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_league_copa_argentina.xml b/app/src/main/res/drawable/bg_league_copa_argentina.xml
new file mode 100644
index 0000000..581d1d7
--- /dev/null
+++ b/app/src/main/res/drawable/bg_league_copa_argentina.xml
@@ -0,0 +1,27 @@
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_league_libertadores.xml b/app/src/main/res/drawable/bg_league_libertadores.xml
new file mode 100644
index 0000000..352d6fe
--- /dev/null
+++ b/app/src/main/res/drawable/bg_league_libertadores.xml
@@ -0,0 +1,27 @@
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_league_liga_profesional.xml b/app/src/main/res/drawable/bg_league_liga_profesional.xml
new file mode 100644
index 0000000..919cf1e
--- /dev/null
+++ b/app/src/main/res/drawable/bg_league_liga_profesional.xml
@@ -0,0 +1,27 @@
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
diff --git a/app/src/main/res/drawable/event_card_background.xml b/app/src/main/res/drawable/event_card_background.xml
new file mode 100644
index 0000000..e7a9fd0
--- /dev/null
+++ b/app/src/main/res/drawable/event_card_background.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/event_card_background_focused.xml b/app/src/main/res/drawable/event_card_background_focused.xml
new file mode 100644
index 0000000..3034fb4
--- /dev/null
+++ b/app/src/main/res/drawable/event_card_background_focused.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/event_card_chip_primary.xml b/app/src/main/res/drawable/event_card_chip_primary.xml
new file mode 100644
index 0000000..6675330
--- /dev/null
+++ b/app/src/main/res/drawable/event_card_chip_primary.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/event_card_chip_secondary.xml b/app/src/main/res/drawable/event_card_chip_secondary.xml
new file mode 100644
index 0000000..14a29fc
--- /dev/null
+++ b/app/src/main/res/drawable/event_card_chip_secondary.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/card_event.xml b/app/src/main/res/layout/card_event.xml
new file mode 100644
index 0000000..caea1a6
--- /dev/null
+++ b/app/src/main/res/layout/card_event.xml
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+