feat: implement full Kodi & DRM support

This commit is contained in:
tunbmoon
2026-04-09 14:07:51 +08:00
parent a7c2c923de
commit fa7743c04c
5 changed files with 132 additions and 15 deletions
@@ -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()