Release v1.1.0
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.futbollibre.tv"
|
applicationId = "com.futbollibre.tv"
|
||||||
minSdk = 21
|
minSdk = 21
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "1.0"
|
versionName = "1.1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -46,6 +46,7 @@ dependencies {
|
|||||||
|
|
||||||
// ExoPlayer for HLS streaming
|
// ExoPlayer for HLS streaming
|
||||||
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
implementation("androidx.media3:media3-exoplayer:1.2.1")
|
||||||
|
implementation("androidx.media3:media3-datasource-okhttp:1.2.1")
|
||||||
implementation("androidx.media3:media3-exoplayer-dash:1.2.1")
|
implementation("androidx.media3:media3-exoplayer-dash:1.2.1")
|
||||||
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
|
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
|
||||||
implementation("androidx.media3:media3-ui:1.2.1")
|
implementation("androidx.media3:media3-ui:1.2.1")
|
||||||
@@ -60,6 +61,7 @@ dependencies {
|
|||||||
|
|
||||||
// OkHttp for HTTP requests
|
// OkHttp for HTTP requests
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation("com.squareup.okhttp3:okhttp-dnsoverhttps:4.12.0")
|
||||||
|
|
||||||
// Jsoup for HTML parsing
|
// Jsoup for HTML parsing
|
||||||
implementation("org.jsoup:jsoup:1.17.2")
|
implementation("org.jsoup:jsoup:1.17.2")
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.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())
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ import com.futbollibre.tv.model.Channel
|
|||||||
import com.futbollibre.tv.model.StreamOption
|
import com.futbollibre.tv.model.StreamOption
|
||||||
import com.futbollibre.tv.model.StreamType
|
import com.futbollibre.tv.model.StreamType
|
||||||
import com.futbollibre.tv.model.StreamUrl
|
import com.futbollibre.tv.model.StreamUrl
|
||||||
|
import com.futbollibre.tv.util.AgendaTimeFormatter
|
||||||
|
import com.futbollibre.tv.util.NetworkStack
|
||||||
|
import com.futbollibre.tv.util.StreamOptionMetadata
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import org.jsoup.Jsoup
|
import org.jsoup.Jsoup
|
||||||
import org.jsoup.nodes.Element
|
import org.jsoup.nodes.Element
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class StreamRepository {
|
class StreamRepository {
|
||||||
|
|
||||||
@@ -27,13 +28,7 @@ class StreamRepository {
|
|||||||
"Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
"Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = NetworkStack.httpClient
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
|
||||||
.followRedirects(true)
|
|
||||||
.followSslRedirects(true)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the current agenda from the remote site.
|
* Fetches the current agenda from the remote site.
|
||||||
@@ -80,7 +75,8 @@ class StreamRepository {
|
|||||||
|
|
||||||
private fun parseAgendaItem(item: Element, index: Int): Channel? {
|
private fun parseAgendaItem(item: Element, index: Int): Channel? {
|
||||||
val headerLink = item.children().firstOrNull { it.tagName() == "a" } ?: return null
|
val headerLink = item.children().firstOrNull { it.tagName() == "a" } ?: return null
|
||||||
val time = headerLink.selectFirst("span.t")?.text()?.trim()
|
val rawTime = headerLink.selectFirst("span.t")?.text()?.trim()
|
||||||
|
val time = AgendaTimeFormatter.format(rawTime)
|
||||||
val fullTitle = headerLink.ownText().trim()
|
val fullTitle = headerLink.ownText().trim()
|
||||||
if (fullTitle.isBlank()) {
|
if (fullTitle.isBlank()) {
|
||||||
return null
|
return null
|
||||||
@@ -94,12 +90,14 @@ class StreamRepository {
|
|||||||
val url = normalizeUrl(link.attr("href"), AGENDA_URL) ?: return@mapNotNull null
|
val url = normalizeUrl(link.attr("href"), AGENDA_URL) ?: return@mapNotNull null
|
||||||
val quality = link.selectFirst("span")?.text()?.trim().orEmpty()
|
val quality = link.selectFirst("span")?.text()?.trim().orEmpty()
|
||||||
val label = link.ownText().trim()
|
val label = link.ownText().trim()
|
||||||
|
val language = StreamOptionMetadata.inferLanguage(label, url)
|
||||||
|
|
||||||
StreamOption(
|
StreamOption(
|
||||||
name = label.ifBlank { "Opcion" },
|
name = label.ifBlank { "Opcion" },
|
||||||
url = url,
|
url = url,
|
||||||
quality = quality,
|
quality = quality,
|
||||||
description = if (quality.isBlank()) null else quality
|
description = if (quality.isBlank()) null else quality,
|
||||||
|
language = language
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,9 +199,7 @@ class StreamRepository {
|
|||||||
)
|
)
|
||||||
val match = idPattern.find(html) ?: return null
|
val match = idPattern.find(html) ?: return null
|
||||||
val mediaUrl = normalizeMediaUrl(match.groupValues[1]) ?: return null
|
val mediaUrl = normalizeMediaUrl(match.groupValues[1]) ?: return null
|
||||||
val clearKeys = Regex("""['"]([0-9a-fA-F]+)['"]\s*:\s*['"]([0-9a-fA-F]+)['"]""")
|
val clearKeys = extractClearKeys(match.groupValues[2])
|
||||||
.findAll(match.groupValues[2])
|
|
||||||
.associate { keyMatch -> keyMatch.groupValues[1] to keyMatch.groupValues[2] }
|
|
||||||
|
|
||||||
return StreamUrl(
|
return StreamUrl(
|
||||||
url = mediaUrl,
|
url = mediaUrl,
|
||||||
@@ -231,14 +227,41 @@ class StreamRepository {
|
|||||||
?: extractObfuscatedPlaybackUrl(html)
|
?: extractObfuscatedPlaybackUrl(html)
|
||||||
|
|
||||||
val mediaUrl = normalizeMediaUrl(directUrl) ?: return null
|
val mediaUrl = normalizeMediaUrl(directUrl) ?: return null
|
||||||
|
val clearKeys = extractClearKeys(html)
|
||||||
|
|
||||||
return StreamUrl(
|
return StreamUrl(
|
||||||
url = mediaUrl,
|
url = mediaUrl,
|
||||||
referer = pageUrl,
|
referer = pageUrl,
|
||||||
streamType = StreamType.HLS
|
streamType = StreamType.HLS,
|
||||||
|
clearKeys = clearKeys
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun extractClearKeys(rawContent: String): Map<String, String> {
|
||||||
|
val directPairs = Regex("""['"]([0-9a-fA-F]{16,})['"]\s*:\s*['"]([0-9a-fA-F]{16,})['"]""")
|
||||||
|
.findAll(rawContent)
|
||||||
|
.associate { match -> match.groupValues[1] to match.groupValues[2] }
|
||||||
|
|
||||||
|
if (directPairs.isNotEmpty()) {
|
||||||
|
return directPairs
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyIdMatch = Regex("""['"]keyId['"]\s*:\s*['"]([0-9a-fA-F]{16,})['"]""")
|
||||||
|
.find(rawContent)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
val keyMatch = Regex("""['"]key['"]\s*:\s*['"]([0-9a-fA-F]{16,})['"]""")
|
||||||
|
.find(rawContent)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
|
||||||
|
return if (!keyIdMatch.isNullOrBlank() && !keyMatch.isNullOrBlank()) {
|
||||||
|
mapOf(keyIdMatch to keyMatch)
|
||||||
|
} else {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun extractIframeUrl(pageUrl: String, html: String): String? {
|
private fun extractIframeUrl(pageUrl: String, html: String): String? {
|
||||||
val doc = Jsoup.parse(html, pageUrl)
|
val doc = Jsoup.parse(html, pageUrl)
|
||||||
val iframe = doc.selectFirst("iframe[src]") ?: return null
|
val iframe = doc.selectFirst("iframe[src]") ?: return null
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(" · ")
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||