1 Commits

Author SHA1 Message Date
renato97
37c01a4f3c Release v1.1.0 2026-03-10 18:13:38 -03:00
35 changed files with 995 additions and 125 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId = "com.futbollibre.tv" applicationId = "com.futbollibre.tv"
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 2
versionName = "1.0" versionName = "1.1.0"
} }
buildTypes { buildTypes {
@@ -46,6 +46,7 @@ dependencies {
// ExoPlayer for HLS streaming // ExoPlayer for HLS streaming
implementation("androidx.media3:media3-exoplayer:1.2.1") 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-dash:1.2.1")
implementation("androidx.media3:media3-exoplayer-hls:1.2.1") implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1") implementation("androidx.media3:media3-ui:1.2.1")
@@ -60,6 +61,7 @@ dependencies {
// OkHttp for HTTP requests // OkHttp for HTTP requests
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0")
// Jsoup for HTML parsing // Jsoup for HTML parsing
implementation("org.jsoup:jsoup:1.17.2") implementation("org.jsoup:jsoup:1.17.2")

View File

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

View File

@@ -1,14 +1,20 @@
package com.futbollibre.tv package com.futbollibre.tv
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.leanback.widget.ImageCardView
import androidx.leanback.widget.Presenter import androidx.leanback.widget.Presenter
import coil.imageLoader
import coil.request.ImageRequest
import com.futbollibre.tv.model.Channel 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. * Presenter for displaying agenda items as cards in the Leanback UI.
@@ -19,102 +25,61 @@ class ChannelCardPresenter(
private val cardHeight: Int = 200 private val cardHeight: Int = 200
) : Presenter() { ) : Presenter() {
companion object { private val defaultCardImage: Drawable? = ContextCompat.getDrawable(context, R.drawable.ic_channel_default)
private const val TAG = "ChannelCardPresenter" private val defaultCardBackground = ContextCompat.getDrawable(context, R.drawable.event_card_background)
} private val focusedCardBackground = ContextCompat.getDrawable(context, R.drawable.event_card_background_focused)
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 { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val cardView = ImageCardView(context).apply { val cardView = LayoutInflater.from(context).inflate(R.layout.card_event, parent, false).apply {
cardType = ImageCardView.CARD_TYPE_INFO_UNDER
isFocusable = true isFocusable = true
isFocusableInTouchMode = true isFocusableInTouchMode = true
setMainImageDimensions(cardWidth, cardHeight) layoutParams = ViewGroup.LayoutParams(cardWidth, cardHeight)
background = defaultCardBackground
// Set background colors clipToOutline = true
setBackgroundColor(defaultBackgroundColor)
} }
updateFocusState(cardView, hasFocus = false)
cardView.setOnFocusChangeListener { view, hasFocus -> updateFocusState(view, hasFocus) }
return ViewHolder(cardView) return ViewHolder(cardView)
} }
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) { override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val channel = item as Channel 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()) { title.text = channel.name
"${channel.streamUrls.size} opciones" time.text = channel.startTime ?: "--:--"
} else { providers.text = StreamOptionMetadata.providerSummary(channel.streamUrls)
"Sin opciones"
} val languageSummary = StreamOptionMetadata.languageSummary(channel.streamUrls)
cardView.contentText = contentText languages.text = when {
!languageSummary.isNullOrBlank() -> languageSummary
cardView.setBackgroundColor(defaultBackgroundColor) channel.streamUrls.isEmpty() -> "Sin links"
else -> "${channel.streamUrls.size} opciones"
if (!channel.logoUrl.isNullOrEmpty()) {
loadChannelLogo(cardView, channel.logoUrl)
} else {
cardView.mainImage = defaultCardImage
} }
languages.visibility = View.VISIBLE
} }
override fun onUnbindViewHolder(viewHolder: ViewHolder) { override fun onUnbindViewHolder(viewHolder: ViewHolder) {
val cardView = viewHolder.view as ImageCardView viewHolder.view.findViewById<ImageView>(R.id.card_watermark).setImageDrawable(null)
cardView.badgeImage = null
cardView.mainImage = null
} }
override fun onViewAttachedToWindow(viewHolder: ViewHolder) { private fun updateFocusState(view: View, hasFocus: Boolean) {
super.onViewAttachedToWindow(viewHolder) val container = view as FrameLayout
} container.background = if (hasFocus) focusedCardBackground else defaultCardBackground
container.animate()
private fun loadChannelLogo(cardView: ImageCardView, logoUrl: String) { .scaleX(if (hasFocus) 1.06f else 1f)
cardView.mainImage = defaultCardImage .scaleY(if (hasFocus) 1.06f else 1f)
loadImageAsync(cardView, logoUrl) .setDuration(120)
} .start()
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)
} }
} }

View File

@@ -23,6 +23,7 @@ import androidx.lifecycle.lifecycleScope
import com.futbollibre.tv.model.Channel import com.futbollibre.tv.model.Channel
import com.futbollibre.tv.repository.StreamRepository import com.futbollibre.tv.repository.StreamRepository
import com.futbollibre.tv.ui.detail.ChannelDetailsActivity import com.futbollibre.tv.ui.detail.ChannelDetailsActivity
import com.futbollibre.tv.util.LeagueArt
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -77,7 +78,7 @@ class MainFragment : BrowseSupportFragment() {
backgroundManager = BackgroundManager.getInstance(requireActivity()) backgroundManager = BackgroundManager.getInstance(requireActivity())
backgroundManager.attach(requireActivity().window) backgroundManager.attach(requireActivity().window)
defaultBackground = ContextCompat.getDrawable(requireContext(), R.drawable.default_background) defaultBackground = ContextCompat.getDrawable(requireContext(), R.drawable.bg_agenda_default)
backgroundManager.drawable = defaultBackground backgroundManager.drawable = defaultBackground
} }
@@ -92,6 +93,12 @@ class MainFragment : BrowseSupportFragment() {
onItemViewSelectedListener = OnItemViewSelectedListener { _, item, _, _ -> onItemViewSelectedListener = OnItemViewSelectedListener { _, item, _, _ ->
if (item is Channel) { if (item is Channel) {
Log.d(TAG, "Event selected: ${item.name}") 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 adapter = rowsAdapter
selectedPosition = 0 selectedPosition = 0
backgroundManager.drawable = ContextCompat.getDrawable(
requireContext(),
LeagueArt.backgroundResId(channels.firstOrNull()?.category)
) ?: defaultBackground
} }
private fun showError(message: String) { private fun showError(message: String) {

View File

@@ -348,6 +348,15 @@ class PlayerActivity : FragmentActivity(), PlayerStateListener {
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
Log.e(TAG, "Player error", error) 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) { val errorMessage = when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> "Error de conexion a internet" PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -> "Error de conexion a internet"
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, 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_NO_PERMISSION -> "Sin permisos para acceder al stream"
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS -> "Error HTTP: ${error.message}" 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_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}" 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) showError(errorMessage)
} }

View File

@@ -25,7 +25,8 @@ data class StreamOption(
val name: String, val name: String,
val url: String, val url: String,
val quality: String = "", val quality: String = "",
val description: String? = null val description: String? = null,
val language: String? = null
) : Parcelable ) : 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.Player
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.datasource.DefaultDataSource 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.ExoPlayer
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
import androidx.media3.exoplayer.drm.FrameworkMediaDrm import androidx.media3.exoplayer.drm.FrameworkMediaDrm
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector
import com.futbollibre.tv.model.StreamType import com.futbollibre.tv.model.StreamType
import com.futbollibre.tv.model.StreamUrl import com.futbollibre.tv.model.StreamUrl
import com.futbollibre.tv.util.NetworkStack
/** /**
* Manager class for ExoPlayer instance. * Manager class for ExoPlayer instance.
@@ -49,11 +51,9 @@ class ExoPlayerManager private constructor() {
): ExoPlayer { ): ExoPlayer {
Log.d(TAG, "Creating ExoPlayer instance") Log.d(TAG, "Creating ExoPlayer instance")
val httpDataSourceFactory = DefaultHttpDataSource.Factory() val httpDataSourceFactory = NetworkStack.mediaHttpDataSourceFactory(
.setUserAgent(USER_AGENT) userAgent = USER_AGENT
.setConnectTimeoutMs(CONNECT_TIMEOUT_MS) )
.setReadTimeoutMs(READ_TIMEOUT_MS)
.setAllowCrossProtocolRedirects(true)
val dataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory) val dataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
@@ -76,22 +76,18 @@ class ExoPlayerManager private constructor() {
fun prepareStream(player: ExoPlayer, streamUrl: StreamUrl, context: Context) { fun prepareStream(player: ExoPlayer, streamUrl: StreamUrl, context: Context) {
Log.d(TAG, "Preparing ${streamUrl.streamType} stream: ${streamUrl.url}") Log.d(TAG, "Preparing ${streamUrl.streamType} stream: ${streamUrl.url}")
val httpDataSourceFactory = DefaultHttpDataSource.Factory() val requestProperties = mutableMapOf<String, String>()
.setUserAgent(USER_AGENT)
.setConnectTimeoutMs(CONNECT_TIMEOUT_MS)
.setReadTimeoutMs(READ_TIMEOUT_MS)
.setAllowCrossProtocolRedirects(true)
streamUrl.referer?.let { referer -> streamUrl.referer?.let { referer ->
Log.d(TAG, "Setting Referer header: $referer") Log.d(TAG, "Setting Referer header: $referer")
httpDataSourceFactory.setDefaultRequestProperties( requestProperties["Referer"] = referer
mapOf( requestProperties["Origin"] = extractOrigin(referer)
"Referer" to referer,
"Origin" to extractOrigin(referer)
)
)
} }
val httpDataSourceFactory = NetworkStack.mediaHttpDataSourceFactory(
userAgent = USER_AGENT,
requestProperties = requestProperties
)
val dataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory) val dataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory) val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
val mediaItemBuilder = MediaItem.Builder() 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) val drmSessionManager = buildClearKeyDrmSessionManager(streamUrl.clearKeys)
mediaSourceFactory.setDrmSessionManagerProvider { drmSessionManager }
mediaItemBuilder.setDrmConfiguration( mediaItemBuilder.setDrmConfiguration(
MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID) MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID)
.setPlayClearContentWithoutKey(true) .setPlayClearContentWithoutKey(true)
.build() .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()) 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.StreamOption
import com.futbollibre.tv.model.StreamType import com.futbollibre.tv.model.StreamType
import com.futbollibre.tv.model.StreamUrl 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.jsoup.Jsoup import org.jsoup.Jsoup
import org.jsoup.nodes.Element import org.jsoup.nodes.Element
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
class StreamRepository { 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" "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() private val client = NetworkStack.httpClient
.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. * Fetches the current agenda from the remote site.
@@ -80,7 +75,8 @@ class StreamRepository {
private fun parseAgendaItem(item: Element, index: Int): Channel? { private fun parseAgendaItem(item: Element, index: Int): Channel? {
val headerLink = item.children().firstOrNull { it.tagName() == "a" } ?: return null 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() val fullTitle = headerLink.ownText().trim()
if (fullTitle.isBlank()) { if (fullTitle.isBlank()) {
return null return null
@@ -94,12 +90,14 @@ class StreamRepository {
val url = normalizeUrl(link.attr("href"), AGENDA_URL) ?: return@mapNotNull null val url = normalizeUrl(link.attr("href"), AGENDA_URL) ?: return@mapNotNull null
val quality = link.selectFirst("span")?.text()?.trim().orEmpty() val quality = link.selectFirst("span")?.text()?.trim().orEmpty()
val label = link.ownText().trim() val label = link.ownText().trim()
val language = StreamOptionMetadata.inferLanguage(label, url)
StreamOption( StreamOption(
name = label.ifBlank { "Opcion" }, name = label.ifBlank { "Opcion" },
url = url, url = url,
quality = quality, 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 match = idPattern.find(html) ?: return null
val mediaUrl = normalizeMediaUrl(match.groupValues[1]) ?: return null val mediaUrl = normalizeMediaUrl(match.groupValues[1]) ?: return null
val clearKeys = Regex("""['"]([0-9a-fA-F]+)['"]\s*:\s*['"]([0-9a-fA-F]+)['"]""") val clearKeys = extractClearKeys(match.groupValues[2])
.findAll(match.groupValues[2])
.associate { keyMatch -> keyMatch.groupValues[1] to keyMatch.groupValues[2] }
return StreamUrl( return StreamUrl(
url = mediaUrl, url = mediaUrl,
@@ -231,14 +227,41 @@ class StreamRepository {
?: extractObfuscatedPlaybackUrl(html) ?: extractObfuscatedPlaybackUrl(html)
val mediaUrl = normalizeMediaUrl(directUrl) ?: return null val mediaUrl = normalizeMediaUrl(directUrl) ?: return null
val clearKeys = extractClearKeys(html)
return StreamUrl( return StreamUrl(
url = mediaUrl, url = mediaUrl,
referer = pageUrl, 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? { private fun extractIframeUrl(pageUrl: String, html: String): String? {
val doc = Jsoup.parse(html, pageUrl) val doc = Jsoup.parse(html, pageUrl)
val iframe = doc.selectFirst("iframe[src]") ?: return null 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 android.os.Bundle
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import com.futbollibre.tv.R import com.futbollibre.tv.R
import com.futbollibre.tv.model.Channel
import com.futbollibre.tv.util.LeagueArt
class ChannelDetailsActivity : FragmentActivity() { class ChannelDetailsActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(
LeagueArt.backgroundResId(readChannel()?.category)
)
setContentView(R.layout.activity_details) setContentView(R.layout.activity_details)
if (savedInstanceState == null) { if (savedInstanceState == null) {
@@ -18,4 +23,13 @@ class ChannelDetailsActivity : FragmentActivity() {
.commit() .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.R
import com.futbollibre.tv.model.Channel import com.futbollibre.tv.model.Channel
import com.futbollibre.tv.model.StreamOption import com.futbollibre.tv.model.StreamOption
import com.futbollibre.tv.util.StreamOptionMetadata
class ChannelDetailsFragment : DetailsSupportFragment() { class ChannelDetailsFragment : DetailsSupportFragment() {
@@ -47,7 +48,7 @@ class ChannelDetailsFragment : DetailsSupportFragment() {
Action( Action(
index.toLong(), index.toLong(),
option.name, option.name,
option.quality.ifBlank { option.description.orEmpty() } buildOptionMeta(option)
) )
) )
} }
@@ -84,7 +85,7 @@ class ChannelDetailsFragment : DetailsSupportFragment() {
} }
private fun buildPlayerTitle(option: StreamOption): String { 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 { private fun buildBodyText(channel: Channel): String {
@@ -92,7 +93,8 @@ class ChannelDetailsFragment : DetailsSupportFragment() {
return "Todavia no hay opciones de visualizacion para este evento." 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(" · ") .joinToString(" · ")
.ifBlank { channel.summary.orEmpty() } .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() { private class ActionPresenter : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
val context = parent.context val context = parent.context
@@ -172,6 +181,7 @@ class ChannelDetailsFragment : DetailsSupportFragment() {
label1.text = action.label1 label1.text = action.label1
label2.text = action.label2 label2.text = action.label2
label2.visibility = if (action.label2.isNullOrBlank()) View.GONE else View.VISIBLE
} }
override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit override fun onUnbindViewHolder(viewHolder: ViewHolder) = Unit
@@ -207,7 +217,11 @@ class ChannelDetailsFragment : DetailsSupportFragment() {
return "Todavia no hay opciones de visualizacion para este evento." 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() } .filter { it.isNotBlank() }
.joinToString(" · ") .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>