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" applicationId = "com.futbollibre.tv"
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 3
versionName = "1.0" versionName = "2.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,17 @@ 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 org.json.JSONArray
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
class StreamRepository { 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" "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 +76,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 +91,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
) )
} }
@@ -178,6 +177,10 @@ class StreamRepository {
val finalUrl = it.request.url.toString() val finalUrl = it.request.url.toString()
extractConfiguredStream(finalUrl, html)?.let { stream ->
return Result.success(stream)
}
extractDashStream(finalUrl, html)?.let { stream -> extractDashStream(finalUrl, html)?.let { stream ->
return Result.success(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 pageId = pageUrl.toHttpUrlOrNull()?.queryParameter("id") ?: return null
val idPattern = Regex( val configBlock = extractJsObjectForKey(html, pageId) ?: return null
"""(?s)\b${Regex.escape(pageId)}\s*:\s*\{\s*url:\s*["']([^"']+\.mpd[^"']*)["']\s*,\s*clearkey:\s*\{(.*?)\}\s*,?\s*\}""" val mediaUrl = extractConfiguredMediaUrl(configBlock) ?: return null
) val streamType = when {
val match = idPattern.find(html) ?: return null isDirectDashUrl(mediaUrl) -> StreamType.DASH
val mediaUrl = normalizeMediaUrl(match.groupValues[1]) ?: return null isDirectHlsUrl(mediaUrl) -> StreamType.HLS
val clearKeys = Regex("""['"]([0-9a-fA-F]+)['"]\s*:\s*['"]([0-9a-fA-F]+)['"]""") else -> return null
.findAll(match.groupValues[2]) }
.associate { keyMatch -> keyMatch.groupValues[1] to keyMatch.groupValues[2] }
return StreamUrl( return StreamUrl(
url = mediaUrl, url = mediaUrl,
referer = pageUrl, 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, streamType = StreamType.DASH,
clearKeys = clearKeys clearKeys = extractClearKeys(html)
) )
} }
@@ -231,11 +256,67 @@ 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)
?: 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() 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 { private fun fetchHtml(url: String, referer: String?): String {
val request = buildRequest(url, referer) val request = buildRequest(url, referer)
val response = client.newCall(request).execute() val response = client.newCall(request).execute()

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>