Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f395fbbfcc | ||
|
|
ddb2ca8bba | ||
|
|
37c01a4f3c |
@@ -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")
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(" · ")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
69
app/src/main/java/com/futbollibre/tv/util/LeagueArt.kt
Normal 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
|
||||
}
|
||||
}
|
||||
49
app/src/main/java/com/futbollibre/tv/util/NetworkStack.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
app/src/main/res/drawable-nodpi/league_brasileirao.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
app/src/main/res/drawable-nodpi/league_concacaf_champions.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
app/src/main/res/drawable-nodpi/league_copa_argentina.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
app/src/main/res/drawable-nodpi/league_copa_libertadores.png
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
app/src/main/res/drawable-nodpi/league_liga_betplay.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
app/src/main/res/drawable-nodpi/league_liga_profesional.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
app/src/main/res/drawable-nodpi/league_uefa_champions.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
19
app/src/main/res/drawable/bg_agenda_default.xml
Normal 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>
|
||||
27
app/src/main/res/drawable/bg_league_betplay.xml
Normal 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>
|
||||
27
app/src/main/res/drawable/bg_league_brasileirao.xml
Normal 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>
|
||||
27
app/src/main/res/drawable/bg_league_champions.xml
Normal 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>
|
||||
27
app/src/main/res/drawable/bg_league_concacaf.xml
Normal 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>
|
||||
27
app/src/main/res/drawable/bg_league_copa_argentina.xml
Normal 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>
|
||||
27
app/src/main/res/drawable/bg_league_libertadores.xml
Normal 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>
|
||||
27
app/src/main/res/drawable/bg_league_liga_profesional.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/event_card_background.xml
Normal 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>
|
||||
12
app/src/main/res/drawable/event_card_background_focused.xml
Normal 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>
|
||||
6
app/src/main/res/drawable/event_card_chip_primary.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/event_card_chip_secondary.xml
Normal 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>
|
||||
89
app/src/main/res/layout/card_event.xml
Normal 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>
|
||||