feat: implement full Kodi & DRM support
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
package com.antoniegil.astronia.player
|
||||
|
||||
internal object KodiUrlParser {
|
||||
private const val HTTP_OPTION_UA = "user-agent"
|
||||
|
||||
fun parseKodiUrlOptions(url: String): Map<String, String?> {
|
||||
val index = url.indexOf('|')
|
||||
if (index == -1) return emptyMap()
|
||||
val options = url.drop(index + 1).split("&")
|
||||
return options
|
||||
.filter { it.isNotBlank() }
|
||||
.associate {
|
||||
val pair = it.split("=")
|
||||
val key = pair.getOrNull(0).orEmpty()
|
||||
val value = pair.getOrNull(1)
|
||||
key to value
|
||||
}
|
||||
}
|
||||
|
||||
fun extractUserAgent(url: String): String? {
|
||||
val kodiOptions = parseKodiUrlOptions(url)
|
||||
return kodiOptions[HTTP_OPTION_UA]
|
||||
}
|
||||
|
||||
fun cleanUrl(url: String): String {
|
||||
val index = url.indexOf('|')
|
||||
return if (index == -1) url else url.take(index)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ class Media3Player(private val context: Context) {
|
||||
private var currentHardwareAcceleration: Boolean = true
|
||||
private var initialM3uUrl: String? = null
|
||||
private var actualPlayingUrl: String? = null
|
||||
private var currentLicenseType: String? = null
|
||||
private var currentLicenseKey: String? = null
|
||||
private var currentUserAgent: String? = null
|
||||
private val state = PlayerState(context = context)
|
||||
|
||||
var onPreparedListener: (() -> Unit)? = null
|
||||
@@ -53,7 +56,10 @@ class Media3Player(private val context: Context) {
|
||||
onUrlResolved = { actualPlayingUrl = it }
|
||||
),
|
||||
latencyMonitor = PlayerFactory.createLatencyMonitor { exoPlayer },
|
||||
combinedListener = PlayerListeners.createCombinedListener({ exoPlayer }, state, callbacks)
|
||||
combinedListener = PlayerListeners.createCombinedListener({ exoPlayer }, state, callbacks),
|
||||
licenseType = currentLicenseType,
|
||||
licenseKey = currentLicenseKey,
|
||||
userAgent = currentUserAgent
|
||||
)
|
||||
|
||||
exoPlayer?.addListener(object : androidx.media3.common.Player.Listener {
|
||||
@@ -79,9 +85,16 @@ class Media3Player(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun setDataSource(url: String) {
|
||||
initialM3uUrl = url
|
||||
state.currentMediaUrl = url
|
||||
fun setDataSource(url: String, licenseType: String? = null, licenseKey: String? = null) {
|
||||
val cleanedUrl = KodiUrlParser.cleanUrl(url)
|
||||
val userAgent = KodiUrlParser.extractUserAgent(url)
|
||||
|
||||
initialM3uUrl = cleanedUrl
|
||||
currentLicenseType = licenseType
|
||||
currentLicenseKey = licenseKey
|
||||
currentUserAgent = userAgent
|
||||
|
||||
state.currentMediaUrl = cleanedUrl
|
||||
state.isInitialLoad = true
|
||||
state.hasTriedM3u8Fix = false
|
||||
state.isFixingM3u8 = false
|
||||
@@ -89,7 +102,26 @@ class Media3Player(private val context: Context) {
|
||||
actualPlayingUrl = null
|
||||
|
||||
val currentSurface = surface
|
||||
val isRtmp = url.startsWith("rtmp", ignoreCase = true)
|
||||
val isRtmp = cleanedUrl.startsWith("rtmp", ignoreCase = true)
|
||||
|
||||
val needsRecreate = (licenseType != null && licenseType != currentLicenseType) ||
|
||||
(licenseKey != null && licenseKey != currentLicenseKey) ||
|
||||
(userAgent != null && userAgent != currentUserAgent)
|
||||
|
||||
if (needsRecreate && exoPlayer != null) {
|
||||
val currentPos = exoPlayer?.currentPosition ?: 0L
|
||||
val wasPlaying = exoPlayer?.isPlaying ?: false
|
||||
|
||||
release()
|
||||
createPlayer(currentHardwareAcceleration)
|
||||
|
||||
if (currentPos > 0) {
|
||||
exoPlayer?.seekTo(currentPos)
|
||||
}
|
||||
if (wasPlaying) {
|
||||
state.shouldPlayWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
exoPlayer?.apply {
|
||||
stop()
|
||||
@@ -99,10 +131,10 @@ class Media3Player(private val context: Context) {
|
||||
if (isRtmp) {
|
||||
val rtmpSource = androidx.media3.exoplayer.source.ProgressiveMediaSource.Factory(
|
||||
androidx.media3.datasource.rtmp.RtmpDataSource.Factory()
|
||||
).createMediaSource(androidx.media3.common.MediaItem.fromUri(url))
|
||||
).createMediaSource(androidx.media3.common.MediaItem.fromUri(cleanedUrl))
|
||||
setMediaSource(rtmpSource)
|
||||
} else {
|
||||
setMediaItem(createMediaItem(url))
|
||||
setMediaItem(createMediaItem(cleanedUrl))
|
||||
}
|
||||
|
||||
prepare()
|
||||
@@ -169,7 +201,7 @@ class Media3Player(private val context: Context) {
|
||||
createPlayer(false)
|
||||
|
||||
currentUrl?.let {
|
||||
setDataSource(it)
|
||||
setDataSource(it, currentLicenseType, currentLicenseKey)
|
||||
exoPlayer?.seekTo(currentPos)
|
||||
if (wasPlaying) start()
|
||||
}
|
||||
@@ -215,7 +247,7 @@ class Media3Player(private val context: Context) {
|
||||
createPlayer(enabled)
|
||||
|
||||
currentUrl?.let {
|
||||
setDataSource(it)
|
||||
setDataSource(it, currentLicenseType, currentLicenseKey)
|
||||
exoPlayer?.seekTo(currentPos)
|
||||
if (wasPlaying) start()
|
||||
}
|
||||
|
||||
@@ -12,8 +12,13 @@ import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.RenderersFactory
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
|
||||
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
|
||||
import androidx.media3.exoplayer.drm.HttpMediaDrmCallback
|
||||
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import com.antoniegil.astronia.util.parser.M3U8Channel
|
||||
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
@@ -24,7 +29,10 @@ internal object PlayerFactory {
|
||||
hardwareAcceleration: Boolean,
|
||||
urlUpgradeListener: AnalyticsListener,
|
||||
latencyMonitor: AnalyticsListener,
|
||||
combinedListener: androidx.media3.common.Player.Listener
|
||||
combinedListener: androidx.media3.common.Player.Listener,
|
||||
licenseType: String? = null,
|
||||
licenseKey: String? = null,
|
||||
userAgent: String? = null
|
||||
): ExoPlayer {
|
||||
val tunnelingSupported = try {
|
||||
MediaCodecList(MediaCodecList.ALL_CODECS).codecInfos.any { codecInfo ->
|
||||
@@ -59,6 +67,11 @@ internal object PlayerFactory {
|
||||
.setConnectTimeoutMs(15000)
|
||||
.setReadTimeoutMs(30000)
|
||||
.setAllowCrossProtocolRedirects(true)
|
||||
.apply {
|
||||
if (userAgent != null) {
|
||||
setUserAgent(userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
val compositeDataSourceFactory = androidx.media3.datasource.DefaultDataSource.Factory(
|
||||
context,
|
||||
@@ -68,6 +81,37 @@ internal object PlayerFactory {
|
||||
val mediaSourceFactory = DefaultMediaSourceFactory(context)
|
||||
.setDataSourceFactory(compositeDataSourceFactory)
|
||||
|
||||
if (!licenseType.isNullOrEmpty() && !licenseKey.isNullOrEmpty()) {
|
||||
val drmCallback = when {
|
||||
(licenseType in arrayOf(
|
||||
M3U8Channel.LICENSE_TYPE_CLEAR_KEY,
|
||||
M3U8Channel.LICENSE_TYPE_CLEAR_KEY_2
|
||||
)) && !licenseKey.startsWith("http") -> LocalMediaDrmCallback(licenseKey.toByteArray())
|
||||
|
||||
else -> HttpMediaDrmCallback(licenseKey, dataSourceFactory)
|
||||
}
|
||||
|
||||
val uuid = when (licenseType) {
|
||||
M3U8Channel.LICENSE_TYPE_CLEAR_KEY, M3U8Channel.LICENSE_TYPE_CLEAR_KEY_2 -> C.CLEARKEY_UUID
|
||||
M3U8Channel.LICENSE_TYPE_WIDEVINE -> C.WIDEVINE_UUID
|
||||
M3U8Channel.LICENSE_TYPE_PLAY_READY -> C.PLAYREADY_UUID
|
||||
else -> C.UUID_NIL
|
||||
}
|
||||
|
||||
if (uuid != C.UUID_NIL && FrameworkMediaDrm.isCryptoSchemeSupported(uuid)) {
|
||||
val drmSessionManager = DefaultDrmSessionManager.Builder()
|
||||
.setUuidAndExoMediaDrmProvider(uuid, FrameworkMediaDrm.DEFAULT_PROVIDER)
|
||||
.setMultiSession(
|
||||
licenseType !in arrayOf(
|
||||
M3U8Channel.LICENSE_TYPE_CLEAR_KEY,
|
||||
M3U8Channel.LICENSE_TYPE_CLEAR_KEY_2
|
||||
)
|
||||
)
|
||||
.build(drmCallback)
|
||||
mediaSourceFactory.setDrmSessionManagerProvider { drmSessionManager }
|
||||
}
|
||||
}
|
||||
|
||||
return ExoPlayer.Builder(context)
|
||||
.setRenderersFactory(renderersFactory)
|
||||
.setLoadControl(DefaultLoadControl.Builder()
|
||||
|
||||
@@ -153,7 +153,11 @@ private fun PlayerPageContent(
|
||||
val autoPlayEnabled = SettingsManager.getAutoPlay(context)
|
||||
|
||||
if (currentMediaUrl != uiState.currentChannelUrl) {
|
||||
media3Player.setDataSource(uiState.currentChannelUrl)
|
||||
media3Player.setDataSource(
|
||||
uiState.currentChannelUrl,
|
||||
uiState.currentLicenseType,
|
||||
uiState.currentLicenseKey
|
||||
)
|
||||
media3Player.updateMediaTitle(uiState.videoTitle)
|
||||
if (autoPlayEnabled || uiState.isPlaying) {
|
||||
pendingAutoPlay = true
|
||||
|
||||
@@ -47,7 +47,9 @@ data class PlayerUiState(
|
||||
val isFullscreen: Boolean = false,
|
||||
val availableQualities: List<VideoQuality> = emptyList(),
|
||||
val currentQuality: VideoQuality? = null,
|
||||
val isLocked: Boolean = false
|
||||
val isLocked: Boolean = false,
|
||||
val currentLicenseType: String? = null,
|
||||
val currentLicenseKey: String? = null
|
||||
)
|
||||
|
||||
data class PlayerProgressState(
|
||||
@@ -107,7 +109,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
currentChannelUrl = startChannel.url,
|
||||
currentChannelId = startChannel.id,
|
||||
videoTitle = startChannel.name,
|
||||
shouldScrollToChannel = true
|
||||
shouldScrollToChannel = true,
|
||||
currentLicenseType = startChannel.licenseType,
|
||||
currentLicenseKey = startChannel.licenseKey
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -204,7 +208,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
currentChannelId = startChannel?.id,
|
||||
playlistUrl = if (isM3U8Playlist) url else "",
|
||||
isLoadingChannels = false,
|
||||
shouldScrollToChannel = true
|
||||
shouldScrollToChannel = true,
|
||||
currentLicenseType = startChannel?.licenseType,
|
||||
currentLicenseKey = startChannel?.licenseKey
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
ErrorHandler.logError("PlayerViewModel", "Failed to load channels", e)
|
||||
@@ -301,7 +307,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
|
||||
shouldScrollToChannel = false,
|
||||
actualPlayingUrl = null,
|
||||
availableQualities = emptyList(),
|
||||
currentQuality = null
|
||||
currentQuality = null,
|
||||
currentLicenseType = channel.licenseType,
|
||||
currentLicenseKey = channel.licenseKey
|
||||
)
|
||||
_progressState.value = PlayerProgressState(currentCycleDuration = 20f)
|
||||
watchTimeTracker.reset()
|
||||
|
||||
Reference in New Issue
Block a user