237 lines
8.7 KiB
Kotlin
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)
|
|
}
|
|
})
|
|
}
|