Initial release

This commit is contained in:
renato97
2026-03-10 16:28:04 -03:00
commit c1caef7a96
46 changed files with 3404 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
package com.futbollibre.tv.player
import android.content.Context
import android.util.Base64
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
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.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.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.trackselection.TrackSelector
import com.futbollibre.tv.model.StreamType
import com.futbollibre.tv.model.StreamUrl
/**
* Manager class for ExoPlayer instance.
*/
class ExoPlayerManager private constructor() {
companion object {
private const val TAG = "ExoPlayerManager"
private const val USER_AGENT =
"Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
private const val CONNECT_TIMEOUT_MS = 30_000
private const val READ_TIMEOUT_MS = 30_000
@Volatile
private var instance: ExoPlayerManager? = null
fun getInstance(): ExoPlayerManager {
return instance ?: synchronized(this) {
instance ?: ExoPlayerManager().also { instance = it }
}
}
}
fun createPlayer(
context: Context,
trackSelector: TrackSelector = DefaultTrackSelector(context)
): 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 dataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
return ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory)
.setTrackSelector(trackSelector)
.build()
.apply {
playWhenReady = true
volume = 1f
Log.d(TAG, "ExoPlayer instance created successfully")
}
}
fun prepareHlsStream(player: ExoPlayer, streamUrl: StreamUrl, context: Context) {
prepareStream(player, streamUrl, context)
}
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)
streamUrl.referer?.let { referer ->
Log.d(TAG, "Setting Referer header: $referer")
httpDataSourceFactory.setDefaultRequestProperties(
mapOf(
"Referer" to referer,
"Origin" to extractOrigin(referer)
)
)
}
val dataSourceFactory = DefaultDataSource.Factory(context, httpDataSourceFactory)
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
val mediaItemBuilder = MediaItem.Builder()
.setUri(streamUrl.url)
.setMimeType(
when (streamUrl.streamType) {
StreamType.HLS -> MimeTypes.APPLICATION_M3U8
StreamType.DASH -> MimeTypes.APPLICATION_MPD
}
)
if (streamUrl.streamType == StreamType.DASH && streamUrl.clearKeys.isNotEmpty()) {
val drmSessionManager = buildClearKeyDrmSessionManager(streamUrl.clearKeys)
mediaSourceFactory.setDrmSessionManagerProvider { drmSessionManager }
mediaItemBuilder.setDrmConfiguration(
MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID)
.setPlayClearContentWithoutKey(true)
.build()
)
}
val mediaSource = mediaSourceFactory.createMediaSource(mediaItemBuilder.build())
player.setMediaSource(mediaSource)
player.prepare()
Log.d(TAG, "Stream preparation started")
}
fun prepareStream(player: ExoPlayer, url: String, referer: String?, context: Context) {
val streamType = if (url.contains(".mpd", ignoreCase = true)) {
StreamType.DASH
} else {
StreamType.HLS
}
prepareStream(player, StreamUrl(url = url, referer = referer, streamType = streamType), context)
}
fun releasePlayer(player: ExoPlayer?) {
player?.let {
Log.d(TAG, "Releasing ExoPlayer instance")
it.stop()
it.release()
Log.d(TAG, "ExoPlayer released successfully")
}
}
private fun buildClearKeyDrmSessionManager(clearKeys: Map<String, String>): DefaultDrmSessionManager {
val licenseResponse = buildClearKeyLicenseResponse(clearKeys)
val drmCallback = LocalMediaDrmCallback(licenseResponse)
return DefaultDrmSessionManager.Builder()
.setUuidAndExoMediaDrmProvider(C.CLEARKEY_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER)
.setPlayClearSamplesWithoutKeys(true)
.build(drmCallback)
}
private fun buildClearKeyLicenseResponse(clearKeys: Map<String, String>): ByteArray {
val keysJson = clearKeys.entries.joinToString(",") { (kid, key) ->
"""{"kty":"oct","kid":"${hexToBase64Url(kid)}","k":"${hexToBase64Url(key)}"}"""
}
return """{"keys":[$keysJson],"type":"temporary"}""".toByteArray(Charsets.UTF_8)
}
private fun hexToBase64Url(hex: String): String {
val bytes = hex.chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
return Base64.encodeToString(
bytes,
Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE
)
}
private fun extractOrigin(url: String): String {
return try {
val uri = android.net.Uri.parse(url)
val scheme = uri.scheme ?: "https"
val host = uri.host ?: ""
val port = uri.port
if (port > 0 && port != 80 && port != 443) {
"$scheme://$host:$port"
} else {
"$scheme://$host"
}
} catch (e: Exception) {
Log.e(TAG, "Error extracting origin from URL", e)
""
}
}
}
interface PlayerStateListener {
fun onLoadingChanged(isLoading: Boolean)
fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int)
fun onPlayerError(error: PlaybackException)
fun onTracksChanged(hasVideo: Boolean, hasAudio: Boolean)
}
fun ExoPlayer.addStateListener(listener: PlayerStateListener) {
addListener(object : Player.Listener {
override fun onIsLoadingChanged(isLoading: Boolean) {
listener.onLoadingChanged(isLoading)
}
override fun onPlaybackStateChanged(playbackState: Int) {
listener.onPlayerStateChanged(playWhenReady, playbackState)
}
override fun onPlayerError(error: PlaybackException) {
listener.onPlayerError(error)
}
override fun onTracksChanged(tracks: Tracks) {
var hasVideo = false
var hasAudio = false
for (group in tracks.groups) {
for (i in 0 until group.length) {
if (group.type == C.TRACK_TYPE_VIDEO) hasVideo = true
if (group.type == C.TRACK_TYPE_AUDIO) hasAudio = true
}
}
listener.onTracksChanged(hasVideo, hasAudio)
}
})
}