diff --git a/app/src/main/java/com/antoniegil/astronia/player/KodiUrlParser.kt b/app/src/main/java/com/antoniegil/astronia/player/KodiUrlParser.kt new file mode 100644 index 0000000..3c7c817 --- /dev/null +++ b/app/src/main/java/com/antoniegil/astronia/player/KodiUrlParser.kt @@ -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 { + 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) + } +} diff --git a/app/src/main/java/com/antoniegil/astronia/player/Media3Player.kt b/app/src/main/java/com/antoniegil/astronia/player/Media3Player.kt index 6e90ddf..e32fcf9 100644 --- a/app/src/main/java/com/antoniegil/astronia/player/Media3Player.kt +++ b/app/src/main/java/com/antoniegil/astronia/player/Media3Player.kt @@ -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() } diff --git a/app/src/main/java/com/antoniegil/astronia/player/PlayerFactory.kt b/app/src/main/java/com/antoniegil/astronia/player/PlayerFactory.kt index b457a74..c5d6c3c 100644 --- a/app/src/main/java/com/antoniegil/astronia/player/PlayerFactory.kt +++ b/app/src/main/java/com/antoniegil/astronia/player/PlayerFactory.kt @@ -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() diff --git a/app/src/main/java/com/antoniegil/astronia/ui/page/PlayerPage.kt b/app/src/main/java/com/antoniegil/astronia/ui/page/PlayerPage.kt index c04afef..25c2d53 100644 --- a/app/src/main/java/com/antoniegil/astronia/ui/page/PlayerPage.kt +++ b/app/src/main/java/com/antoniegil/astronia/ui/page/PlayerPage.kt @@ -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 diff --git a/app/src/main/java/com/antoniegil/astronia/viewmodel/PlayerViewModel.kt b/app/src/main/java/com/antoniegil/astronia/viewmodel/PlayerViewModel.kt index aefce88..12f9503 100644 --- a/app/src/main/java/com/antoniegil/astronia/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/com/antoniegil/astronia/viewmodel/PlayerViewModel.kt @@ -47,7 +47,9 @@ data class PlayerUiState( val isFullscreen: Boolean = false, val availableQualities: List = 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()