Files
futbollibre-tv-android/app/src/main/java/com/futbollibre/tv/player/ExoPlayerManager.kt
2026-03-10 18:13:38 -03:00

237 lines
8.7 KiB
Kotlin

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.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.
*/
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 = NetworkStack.mediaHttpDataSourceFactory(
userAgent = USER_AGENT
)
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 requestProperties = mutableMapOf<String, String>()
streamUrl.referer?.let { referer ->
Log.d(TAG, "Setting Referer header: $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()
.setUri(streamUrl.url)
.setMimeType(
when (streamUrl.streamType) {
StreamType.HLS -> MimeTypes.APPLICATION_M3U8
StreamType.DASH -> MimeTypes.APPLICATION_MPD
}
)
if (streamUrl.clearKeys.isNotEmpty()) {
val drmSessionManager = buildClearKeyDrmSessionManager(streamUrl.clearKeys)
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())
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)
}
})
}