Release v1.1.0

This commit is contained in:
renato97
2026-03-10 18:13:38 -03:00
parent c1caef7a96
commit 37c01a4f3c
35 changed files with 995 additions and 125 deletions

View File

@@ -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")

View File

@@ -12,7 +12,7 @@
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
android:required="false" />
<application
android:name=".FutbolLibreApp"
@@ -31,6 +31,10 @@
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />

View File

@@ -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<ImageView>(R.id.card_watermark)
val title = root.findViewById<TextView>(R.id.card_title)
val time = root.findViewById<TextView>(R.id.card_time)
val languages = root.findViewById<TextView>(R.id.card_languages)
val providers = root.findViewById<TextView>(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<ImageView>(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()
}
}

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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
/**

View File

@@ -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<String>
) : HlsPlaylistParserFactory {
private val delegate = DefaultHlsPlaylistParserFactory()
private val clearKeySchemeData = buildClearKeySchemeData(keyIds)
override fun createPlaylistParser(): ParsingLoadable.Parser<HlsPlaylist> {
return wrap(delegate.createPlaylistParser())
}
override fun createPlaylistParser(
multivariantPlaylist: HlsMultivariantPlaylist,
previousMediaPlaylist: HlsMediaPlaylist?
): ParsingLoadable.Parser<HlsPlaylist> {
return wrap(delegate.createPlaylistParser(multivariantPlaylist, previousMediaPlaylist))
}
private fun wrap(
parser: ParsingLoadable.Parser<HlsPlaylist>
): ParsingLoadable.Parser<HlsPlaylist> {
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<String>): 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)
}
}

View File

@@ -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<String, String>()
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())

View File

@@ -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<String, String> {
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

View File

@@ -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")
}
}
}

View File

@@ -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(" · ")

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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<String, String> = emptyMap()
): HttpDataSource.Factory {
return OkHttpDataSource.Factory(httpClient)
.setUserAgent(userAgent)
.setDefaultRequestProperties(requestProperties)
}
}

View File

@@ -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<StreamOption>): 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<StreamOption>): 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
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#081321"
android:startColor="#132C45" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:angle="45"
android:endColor="#DD04070C"
android:startColor="#8804070C" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#0F1F44"
android:startColor="#1C58B5" />
</shape>
</item>
<item
android:gravity="end|center_vertical"
android:right="72dp">
<bitmap
android:antialias="true"
android:gravity="center"
android:src="@drawable/league_liga_betplay" />
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#DD05070C"
android:startColor="#2505070C" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#13311D"
android:startColor="#1B7A4A" />
</shape>
</item>
<item
android:gravity="end|center_vertical"
android:right="72dp">
<bitmap
android:antialias="true"
android:gravity="center"
android:src="@drawable/league_brasileirao" />
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#E0040805"
android:startColor="#30040805" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#0C1831"
android:startColor="#123F7A" />
</shape>
</item>
<item
android:gravity="end|center_vertical"
android:right="72dp">
<bitmap
android:antialias="true"
android:gravity="center"
android:src="@drawable/league_uefa_champions" />
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#DD04070C"
android:startColor="#3304070C" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#112938"
android:startColor="#157E6D" />
</shape>
</item>
<item
android:gravity="end|center_vertical"
android:right="72dp">
<bitmap
android:antialias="true"
android:gravity="center"
android:src="@drawable/league_concacaf_champions" />
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#DD050A0F"
android:startColor="#22050A0F" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#0F1E37"
android:startColor="#2E75D1" />
</shape>
</item>
<item
android:gravity="end|center_vertical"
android:right="72dp">
<bitmap
android:antialias="true"
android:gravity="center"
android:src="@drawable/league_copa_argentina" />
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#DD05080C"
android:startColor="#2205080C" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#27110D"
android:startColor="#8A4732" />
</shape>
</item>
<item
android:gravity="end|center_vertical"
android:right="72dp">
<bitmap
android:antialias="true"
android:gravity="center"
android:src="@drawable/league_copa_libertadores" />
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#E0080707"
android:startColor="#30080707" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#102745"
android:startColor="#1F84D6" />
</shape>
</item>
<item
android:gravity="end|center_vertical"
android:right="72dp">
<bitmap
android:antialias="true"
android:gravity="center"
android:src="@drawable/league_liga_profesional" />
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:angle="0"
android:endColor="#DD071018"
android:startColor="#26071018" />
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="18dp" />
<gradient
android:angle="90"
android:endColor="#E4141C2A"
android:startColor="#F0212B3A" />
</shape>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="18dp" />
<stroke
android:width="2dp"
android:color="#F4F7FF" />
<gradient
android:angle="90"
android:endColor="#F0223650"
android:startColor="#FF2B5585" />
</shape>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="999dp" />
<solid android:color="#CCF3F7FF" />
</shape>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="999dp" />
<solid android:color="#24F3F7FF" />
<stroke
android:width="1dp"
android:color="#44F3F7FF" />
</shape>

View File

@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/card_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/event_card_background"
android:clipToOutline="true">
<ImageView
android:id="@+id/card_watermark"
android:layout_width="150dp"
android:layout_height="150dp"
android:layout_gravity="end|top"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:alpha="0.16"
android:scaleType="fitCenter" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="bottom"
android:orientation="vertical"
android:padding="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/card_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/event_card_chip_primary"
android:paddingStart="10dp"
android:paddingTop="4dp"
android:paddingEnd="10dp"
android:paddingBottom="4dp"
android:textColor="#102132"
android:textSize="11sp"
android:textStyle="bold" />
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/card_languages"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/event_card_chip_secondary"
android:paddingStart="10dp"
android:paddingTop="4dp"
android:paddingEnd="10dp"
android:paddingBottom="4dp"
android:textColor="#F3F7FF"
android:textSize="10sp"
android:textStyle="bold" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/card_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/card_providers"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#C7D4EA"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout>