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() 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): 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): 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) } }) }