3 Commits
v1.0.0 ... main

Author SHA1 Message Date
renato97
f395fbbfcc chore: bump release version to 2.0 2026-03-11 16:17:09 -03:00
renato97
ddb2ca8bba fix: support updated drm event formats 2026-03-11 16:15:06 -03:00
renato97
37c01a4f3c Release v1.1.0 2026-03-10 18:13:38 -03:00
35 changed files with 1252 additions and 132 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId = "com.futbollibre.tv"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "1.0"
versionCode = 3
versionName = "2.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,17 @@ 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 org.json.JSONArray
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
class StreamRepository {
@@ -27,13 +29,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 +76,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 +91,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
)
}
@@ -178,6 +177,10 @@ class StreamRepository {
val finalUrl = it.request.url.toString()
extractConfiguredStream(finalUrl, html)?.let { stream ->
return Result.success(stream)
}
extractDashStream(finalUrl, html)?.let { stream ->
return Result.success(stream)
}
@@ -194,22 +197,44 @@ class StreamRepository {
}
}
private fun extractDashStream(pageUrl: String, html: String): StreamUrl? {
private fun extractConfiguredStream(pageUrl: String, html: String): StreamUrl? {
val pageId = pageUrl.toHttpUrlOrNull()?.queryParameter("id") ?: return null
val idPattern = Regex(
"""(?s)\b${Regex.escape(pageId)}\s*:\s*\{\s*url:\s*["']([^"']+\.mpd[^"']*)["']\s*,\s*clearkey:\s*\{(.*?)\}\s*,?\s*\}"""
)
val match = idPattern.find(html) ?: return null
val mediaUrl = normalizeMediaUrl(match.groupValues[1]) ?: return null
val clearKeys = Regex("""['"]([0-9a-fA-F]+)['"]\s*:\s*['"]([0-9a-fA-F]+)['"]""")
.findAll(match.groupValues[2])
.associate { keyMatch -> keyMatch.groupValues[1] to keyMatch.groupValues[2] }
val configBlock = extractJsObjectForKey(html, pageId) ?: return null
val mediaUrl = extractConfiguredMediaUrl(configBlock) ?: return null
val streamType = when {
isDirectDashUrl(mediaUrl) -> StreamType.DASH
isDirectHlsUrl(mediaUrl) -> StreamType.HLS
else -> return null
}
return StreamUrl(
url = mediaUrl,
referer = pageUrl,
streamType = streamType,
clearKeys = extractClearKeys(configBlock)
)
}
private fun extractDashStream(pageUrl: String, html: String): StreamUrl? {
extractObfuscatedDashStream(pageUrl, html)?.let { return it }
val mediaUrl = extractAssignedString(html, "MPD")
?: extractNamedUrlProperty(html, "file", ".mpd")
?: Regex(
"""https?://[^\s"'\\]+\.mpd(?:\?[^\s"'\\]*)?""",
setOf(RegexOption.IGNORE_CASE)
).find(html)?.value
?: Regex(
"""["'](//[^"']+\.mpd(?:\?[^"']*)?)["']""",
setOf(RegexOption.IGNORE_CASE)
).find(html)?.groupValues?.getOrNull(1)
val normalizedMediaUrl = normalizeMediaUrl(mediaUrl) ?: return null
return StreamUrl(
url = normalizedMediaUrl,
referer = pageUrl,
streamType = StreamType.DASH,
clearKeys = clearKeys
clearKeys = extractClearKeys(html)
)
}
@@ -231,11 +256,67 @@ 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)
?: Regex("""\bk1\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)
?: Regex("""\bk2\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 extractObfuscatedDashStream(pageUrl: String, html: String): StreamUrl? {
val mediaUrl = extractAssignedString(html, "MPD") ?: return null
val normalizedMediaUrl = normalizeMediaUrl(mediaUrl) ?: return null
if (!isDirectDashUrl(normalizedMediaUrl)) {
return null
}
val obfuscatedList = extractAssignedJsonArray(html, "OBF_LIST") ?: return null
val clearKeys = decodeObfuscatedClearKeys(obfuscatedList)
if (clearKeys.isEmpty()) {
return null
}
return StreamUrl(
url = normalizedMediaUrl,
referer = pageUrl,
streamType = StreamType.DASH,
clearKeys = clearKeys
)
}
@@ -287,6 +368,198 @@ class StreamRepository {
return regex.find(html)?.groupValues?.getOrNull(1)?.toIntOrNull()
}
private fun extractConfiguredMediaUrl(configBlock: String): String? {
val directUrl = extractNamedUrlProperty(configBlock, "url", ".mpd")
?: extractNamedUrlProperty(configBlock, "url", ".m3u8")
?: extractNamedUrlProperty(configBlock, "file", ".mpd")
?: extractNamedUrlProperty(configBlock, "file", ".m3u8")
return normalizeMediaUrl(directUrl)
}
private fun extractNamedUrlProperty(content: String, propertyName: String, extension: String): String? {
val propertyRegex = Regex(
"""(?is)(?:(["'])${Regex.escape(propertyName)}\1|${Regex.escape(propertyName)})\s*:\s*["']([^"']*${Regex.escape(extension)}[^"']*)["']"""
)
return propertyRegex.find(content)?.groupValues?.getOrNull(2)
}
private fun extractAssignedString(content: String, variableName: String): String? {
val pattern = Regex(
"""(?is)(?:const|let|var)?\s*${Regex.escape(variableName)}\s*=\s*["']([^"']+)["']"""
)
return pattern.find(content)?.groupValues?.getOrNull(1)
}
private fun extractAssignedJsonArray(content: String, variableName: String): JSONArray? {
val pattern = Regex(
"""(?is)(?:const|let|var)?\s*${Regex.escape(variableName)}\s*=\s*(\[[\s\S]*?])\s*;"""
)
val rawArray = pattern.find(content)?.groupValues?.getOrNull(1) ?: return null
return try {
JSONArray(rawArray)
} catch (_: Exception) {
null
}
}
private fun extractJsObjectForKey(content: String, keyName: String): String? {
val candidateKeys = linkedSetOf(keyName, keyName.uppercase(), keyName.lowercase())
for (candidate in candidateKeys) {
val entryPattern = Regex(
"""(?is)(?:(["'])${Regex.escape(candidate)}\1|${Regex.escape(candidate)})\s*:\s*\{"""
)
val match = entryPattern.find(content) ?: continue
val objectStart = match.range.last
extractBalancedBlock(content, objectStart, '{', '}')?.let { return it }
}
return null
}
private fun extractBalancedBlock(content: String, startIndex: Int, openChar: Char, closeChar: Char): String? {
if (startIndex !in content.indices || content[startIndex] != openChar) {
return null
}
var depth = 0
var inSingleQuote = false
var inDoubleQuote = false
var escaped = false
for (index in startIndex until content.length) {
val current = content[index]
if (escaped) {
escaped = false
continue
}
when {
current == '\\' && (inSingleQuote || inDoubleQuote) -> {
escaped = true
}
current == '\'' && !inDoubleQuote -> {
inSingleQuote = !inSingleQuote
}
current == '"' && !inSingleQuote -> {
inDoubleQuote = !inDoubleQuote
}
!inSingleQuote && !inDoubleQuote && current == openChar -> {
depth++
}
!inSingleQuote && !inDoubleQuote && current == closeChar -> {
depth--
if (depth == 0) {
return content.substring(startIndex, index + 1)
}
}
}
}
return null
}
private fun decodeObfuscatedClearKeys(obfuscatedList: JSONArray): Map<String, String> {
val clearKeys = linkedMapOf<String, String>()
for (index in 0 until obfuscatedList.length()) {
val item = obfuscatedList.optJSONObject(index) ?: continue
val bytes = decodeObfuscatedBytes(item) ?: continue
if (bytes.size < 32) {
continue
}
val kidHex = bytes.copyOfRange(0, 16).toHexString()
val keyHex = bytes.copyOfRange(16, 32).toHexString()
clearKeys[kidHex] = keyHex
}
return clearKeys
}
private fun decodeObfuscatedBytes(item: org.json.JSONObject): ByteArray? {
val chunksA = readNestedIntLists(item, "chunksA") ?: return null
val chunksB = readNestedIntLists(item, "chunksB") ?: return null
val posA = readNestedIntLists(item, "posA") ?: return null
val posB = readNestedIntLists(item, "posB") ?: return null
val invPerm = readIntList(item.optJSONArray("invPerm")) ?: return null
val expectedLength = item.optInt("len", -1)
val acc = ArrayList<Int>(expectedLength.coerceAtLeast(0))
val accMask = ArrayList<Int>(expectedLength.coerceAtLeast(0))
val sectionCount = minOf(chunksA.size, chunksB.size, posA.size, posB.size)
for (sectionIndex in 0 until sectionCount) {
val chunkA = chunksA[sectionIndex]
val chunkB = chunksB[sectionIndex]
val positionsA = posA[sectionIndex]
val positionsB = posB[sectionIndex]
val length = minOf(positionsA.size, positionsB.size)
for (positionIndex in 0 until length) {
val aIndex = positionsA[positionIndex]
val bIndex = positionsB[positionIndex]
if (aIndex !in chunkA.indices || bIndex !in chunkB.indices) {
return null
}
acc += chunkA[aIndex]
accMask += chunkB[bIndex]
}
}
if (expectedLength <= 0 || acc.size != expectedLength || accMask.size != expectedLength || invPerm.size != expectedLength) {
return null
}
val permuted = ByteArray(expectedLength)
for (i in 0 until expectedLength) {
permuted[i] = ((acc[i] xor accMask[i]) and 0xFF).toByte()
}
val output = ByteArray(expectedLength)
for (i in 0 until expectedLength) {
val sourceIndex = invPerm[i]
if (sourceIndex !in permuted.indices) {
return null
}
output[i] = permuted[sourceIndex]
}
return output
}
private fun readNestedIntLists(item: org.json.JSONObject, key: String): List<List<Int>>? {
val array = item.optJSONArray(key) ?: return null
return buildList(array.length()) {
for (index in 0 until array.length()) {
val row = readIntList(array.optJSONArray(index)) ?: return null
add(row)
}
}
}
private fun readIntList(array: JSONArray?): List<Int>? {
if (array == null) {
return null
}
return buildList(array.length()) {
for (index in 0 until array.length()) {
if (array.isNull(index)) {
return null
}
add(array.optInt(index))
}
}
}
private fun ByteArray.toHexString(): String {
return joinToString(separator = "") { byte ->
"%02x".format(byte.toInt() and 0xFF)
}
}
private fun fetchHtml(url: String, referer: String?): String {
val request = buildRequest(url, referer)
val response = client.newCall(request).execute()

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>