diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 23bd129..278b74b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.futbollibre.tv" minSdk = 21 targetSdk = 34 - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "1.1.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") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 32812ca..f493e49 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,7 +12,7 @@ android:required="false" /> + android:required="false" /> + + + + diff --git a/app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt b/app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt index ca4803e..da3e5af 100644 --- a/app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt +++ b/app/src/main/java/com/futbollibre/tv/ChannelCardPresenter.kt @@ -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(R.id.card_watermark) + val title = root.findViewById(R.id.card_title) + val time = root.findViewById(R.id.card_time) + val languages = root.findViewById(R.id.card_languages) + val providers = root.findViewById(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(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() } } diff --git a/app/src/main/java/com/futbollibre/tv/MainFragment.kt b/app/src/main/java/com/futbollibre/tv/MainFragment.kt index 2a7cbe2..eeacde3 100644 --- a/app/src/main/java/com/futbollibre/tv/MainFragment.kt +++ b/app/src/main/java/com/futbollibre/tv/MainFragment.kt @@ -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) { diff --git a/app/src/main/java/com/futbollibre/tv/PlayerActivity.kt b/app/src/main/java/com/futbollibre/tv/PlayerActivity.kt index 2345da2..d0ce8b4 100644 --- a/app/src/main/java/com/futbollibre/tv/PlayerActivity.kt +++ b/app/src/main/java/com/futbollibre/tv/PlayerActivity.kt @@ -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) } diff --git a/app/src/main/java/com/futbollibre/tv/model/Channel.kt b/app/src/main/java/com/futbollibre/tv/model/Channel.kt index b412906..6e68b69 100644 --- a/app/src/main/java/com/futbollibre/tv/model/Channel.kt +++ b/app/src/main/java/com/futbollibre/tv/model/Channel.kt @@ -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 /** diff --git a/app/src/main/java/com/futbollibre/tv/player/ClearKeyHlsPlaylistParserFactory.kt b/app/src/main/java/com/futbollibre/tv/player/ClearKeyHlsPlaylistParserFactory.kt new file mode 100644 index 0000000..88ec4ec --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/player/ClearKeyHlsPlaylistParserFactory.kt @@ -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 +) : HlsPlaylistParserFactory { + + private val delegate = DefaultHlsPlaylistParserFactory() + private val clearKeySchemeData = buildClearKeySchemeData(keyIds) + + override fun createPlaylistParser(): ParsingLoadable.Parser { + return wrap(delegate.createPlaylistParser()) + } + + override fun createPlaylistParser( + multivariantPlaylist: HlsMultivariantPlaylist, + previousMediaPlaylist: HlsMediaPlaylist? + ): ParsingLoadable.Parser { + return wrap(delegate.createPlaylistParser(multivariantPlaylist, previousMediaPlaylist)) + } + + private fun wrap( + parser: ParsingLoadable.Parser + ): ParsingLoadable.Parser { + 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): 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) + } +} diff --git a/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt b/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt index 9ecfc68..0da995e 100644 --- a/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt +++ b/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt @@ -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() 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()) diff --git a/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt b/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt index edbc781..a5506b6 100644 --- a/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt +++ b/app/src/main/java/com/futbollibre/tv/repository/StreamRepository.kt @@ -6,15 +6,16 @@ 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 java.nio.charset.StandardCharsets -import java.util.concurrent.TimeUnit 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" } - 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 +75,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 +90,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 ) } @@ -201,9 +199,7 @@ class StreamRepository { ) 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 clearKeys = extractClearKeys(match.groupValues[2]) return StreamUrl( url = mediaUrl, @@ -231,14 +227,41 @@ 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 { + 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? { val doc = Jsoup.parse(html, pageUrl) val iframe = doc.selectFirst("iframe[src]") ?: return null diff --git a/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsActivity.kt b/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsActivity.kt index a6fdb50..50ffd0c 100644 --- a/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsActivity.kt +++ b/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsActivity.kt @@ -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") + } + } } diff --git a/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsFragment.kt b/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsFragment.kt index 253b8d8..271acf2 100644 --- a/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsFragment.kt +++ b/app/src/main/java/com/futbollibre/tv/ui/detail/ChannelDetailsFragment.kt @@ -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(" · ") diff --git a/app/src/main/java/com/futbollibre/tv/util/AgendaTimeFormatter.kt b/app/src/main/java/com/futbollibre/tv/util/AgendaTimeFormatter.kt new file mode 100644 index 0000000..a641f49 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/util/AgendaTimeFormatter.kt @@ -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 +} diff --git a/app/src/main/java/com/futbollibre/tv/util/LeagueArt.kt b/app/src/main/java/com/futbollibre/tv/util/LeagueArt.kt new file mode 100644 index 0000000..4060f3a --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/util/LeagueArt.kt @@ -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 + } +} diff --git a/app/src/main/java/com/futbollibre/tv/util/NetworkStack.kt b/app/src/main/java/com/futbollibre/tv/util/NetworkStack.kt new file mode 100644 index 0000000..51a3dd8 --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/util/NetworkStack.kt @@ -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 = emptyMap() + ): HttpDataSource.Factory { + return OkHttpDataSource.Factory(httpClient) + .setUserAgent(userAgent) + .setDefaultRequestProperties(requestProperties) + } +} diff --git a/app/src/main/java/com/futbollibre/tv/util/StreamOptionMetadata.kt b/app/src/main/java/com/futbollibre/tv/util/StreamOptionMetadata.kt new file mode 100644 index 0000000..19014ce --- /dev/null +++ b/app/src/main/java/com/futbollibre/tv/util/StreamOptionMetadata.kt @@ -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): 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): 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 + } + } +} diff --git a/app/src/main/res/drawable-nodpi/league_brasileirao.png b/app/src/main/res/drawable-nodpi/league_brasileirao.png new file mode 100644 index 0000000..af02b12 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_brasileirao.png differ diff --git a/app/src/main/res/drawable-nodpi/league_concacaf_champions.png b/app/src/main/res/drawable-nodpi/league_concacaf_champions.png new file mode 100644 index 0000000..179ac12 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_concacaf_champions.png differ diff --git a/app/src/main/res/drawable-nodpi/league_copa_argentina.png b/app/src/main/res/drawable-nodpi/league_copa_argentina.png new file mode 100644 index 0000000..7aa7ab3 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_copa_argentina.png differ diff --git a/app/src/main/res/drawable-nodpi/league_copa_libertadores.png b/app/src/main/res/drawable-nodpi/league_copa_libertadores.png new file mode 100644 index 0000000..2e9b0ad Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_copa_libertadores.png differ diff --git a/app/src/main/res/drawable-nodpi/league_liga_betplay.png b/app/src/main/res/drawable-nodpi/league_liga_betplay.png new file mode 100644 index 0000000..d793648 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_liga_betplay.png differ diff --git a/app/src/main/res/drawable-nodpi/league_liga_profesional.png b/app/src/main/res/drawable-nodpi/league_liga_profesional.png new file mode 100644 index 0000000..af9f800 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_liga_profesional.png differ diff --git a/app/src/main/res/drawable-nodpi/league_uefa_champions.png b/app/src/main/res/drawable-nodpi/league_uefa_champions.png new file mode 100644 index 0000000..85cfb7c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/league_uefa_champions.png differ diff --git a/app/src/main/res/drawable/bg_agenda_default.xml b/app/src/main/res/drawable/bg_agenda_default.xml new file mode 100644 index 0000000..fd8f7ee --- /dev/null +++ b/app/src/main/res/drawable/bg_agenda_default.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_league_betplay.xml b/app/src/main/res/drawable/bg_league_betplay.xml new file mode 100644 index 0000000..263d604 --- /dev/null +++ b/app/src/main/res/drawable/bg_league_betplay.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_league_brasileirao.xml b/app/src/main/res/drawable/bg_league_brasileirao.xml new file mode 100644 index 0000000..a7cb536 --- /dev/null +++ b/app/src/main/res/drawable/bg_league_brasileirao.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_league_champions.xml b/app/src/main/res/drawable/bg_league_champions.xml new file mode 100644 index 0000000..8c362fb --- /dev/null +++ b/app/src/main/res/drawable/bg_league_champions.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_league_concacaf.xml b/app/src/main/res/drawable/bg_league_concacaf.xml new file mode 100644 index 0000000..1cd124a --- /dev/null +++ b/app/src/main/res/drawable/bg_league_concacaf.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_league_copa_argentina.xml b/app/src/main/res/drawable/bg_league_copa_argentina.xml new file mode 100644 index 0000000..581d1d7 --- /dev/null +++ b/app/src/main/res/drawable/bg_league_copa_argentina.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_league_libertadores.xml b/app/src/main/res/drawable/bg_league_libertadores.xml new file mode 100644 index 0000000..352d6fe --- /dev/null +++ b/app/src/main/res/drawable/bg_league_libertadores.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/bg_league_liga_profesional.xml b/app/src/main/res/drawable/bg_league_liga_profesional.xml new file mode 100644 index 0000000..919cf1e --- /dev/null +++ b/app/src/main/res/drawable/bg_league_liga_profesional.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/event_card_background.xml b/app/src/main/res/drawable/event_card_background.xml new file mode 100644 index 0000000..e7a9fd0 --- /dev/null +++ b/app/src/main/res/drawable/event_card_background.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/event_card_background_focused.xml b/app/src/main/res/drawable/event_card_background_focused.xml new file mode 100644 index 0000000..3034fb4 --- /dev/null +++ b/app/src/main/res/drawable/event_card_background_focused.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/event_card_chip_primary.xml b/app/src/main/res/drawable/event_card_chip_primary.xml new file mode 100644 index 0000000..6675330 --- /dev/null +++ b/app/src/main/res/drawable/event_card_chip_primary.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/event_card_chip_secondary.xml b/app/src/main/res/drawable/event_card_chip_secondary.xml new file mode 100644 index 0000000..14a29fc --- /dev/null +++ b/app/src/main/res/drawable/event_card_chip_secondary.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/layout/card_event.xml b/app/src/main/res/layout/card_event.xml new file mode 100644 index 0000000..caea1a6 --- /dev/null +++ b/app/src/main/res/layout/card_event.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + +