Fix ClearKey live playback by using LocalMediaDrmCallback.

OkHttp cannot fetch data: license URIs, which caused SOURCE_ERROR on DRM channels.
This commit is contained in:
Jos Vooges | STH 2026-09-18 19:37:27 +02:00
parent 0700c79878
commit 2a734cbcd1

View file

@ -16,6 +16,10 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.audio.AudioRendererEventListener import androidx.media3.exoplayer.audio.AudioRendererEventListener
import androidx.media3.exoplayer.audio.AudioSink import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager
import androidx.media3.exoplayer.drm.DrmSessionManager
import androidx.media3.exoplayer.drm.FrameworkMediaDrm
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
@ -26,7 +30,8 @@ import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE
import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER import androidx.media3.exoplayer.DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import nl.vonas.mediacluster.tv.data.ClearKeyEntry
import nl.vonas.mediacluster.tv.data.DrmInfo
/** /**
* Hardware-video + configureerbare audio. * Hardware-video + configureerbare audio.
* - PCM (standaard): FFmpeg prefer → decode naar PCM (veilig op TV zonder AVR-decoder). * - PCM (standaard): FFmpeg prefer → decode naar PCM (veilig op TV zonder AVR-decoder).
@ -185,7 +190,7 @@ private val liveOkHttp: OkHttpClient by lazy {
.build() .build()
} }
/** Live IPTV: TS-first, kleine buffer, lage latency. */ /** Live IPTV: TS-first, kleine buffer, lage latency. ClearKey via LocalMediaDrmCallback (geen data:-URI). */
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
fun buildLiveTvPlayer(context: Context): ExoPlayer { fun buildLiveTvPlayer(context: Context): ExoPlayer {
val appContext = context.applicationContext val appContext = context.applicationContext
@ -216,9 +221,14 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build() .build()
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory)
.setDrmSessionManagerProvider { mediaItem ->
clearKeyDrmSessionManagerFromTag(mediaItem) ?: DrmSessionManager.DRM_UNSUPPORTED
}
return ExoPlayer.Builder(appContext, renderersFactory) return ExoPlayer.Builder(appContext, renderersFactory)
.setTrackSelector(trackSelector) .setTrackSelector(trackSelector)
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) .setMediaSourceFactory(mediaSourceFactory)
.setLoadControl(loadControl) .setLoadControl(loadControl)
.setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true) .setAudioAttributes(audioAttributes, /* handleAudioFocus = */ true)
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
@ -227,11 +237,26 @@ fun buildLiveTvPlayer(context: Context): ExoPlayer {
.also { it.volume = 1f } .also { it.volume = 1f }
} }
/** ClearKey-keys in MediaItem.tag — OkHttp kan geen data:-license-URI laden. */
private data class ClearKeyLicenseTag(val json: String)
@OptIn(UnstableApi::class)
private fun clearKeyDrmSessionManagerFromTag(mediaItem: MediaItem): DrmSessionManager? {
val tag = mediaItem.localConfiguration?.tag as? ClearKeyLicenseTag ?: return null
val callback = LocalMediaDrmCallback(tag.json.toByteArray(Charsets.UTF_8))
return DefaultDrmSessionManager.Builder()
.setUuidAndExoMediaDrmProvider(C.CLEARKEY_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER)
.setMultiSession(false)
.setPlayClearSamplesWithoutKeys(true)
.build(callback)
}
/** TS / HLS / DASH(+ClearKey). */ /** TS / HLS / DASH(+ClearKey). */
@OptIn(UnstableApi::class)
fun liveMediaItem( fun liveMediaItem(
url: String, url: String,
format: String, format: String,
drm: nl.vonas.mediacluster.tv.data.DrmInfo? = null, drm: DrmInfo? = null,
): MediaItem { ): MediaItem {
val isDash = format.equals("dash", ignoreCase = true) || val isDash = format.equals("dash", ignoreCase = true) ||
url.contains(".mpd", ignoreCase = true) url.contains(".mpd", ignoreCase = true)
@ -265,23 +290,16 @@ fun liveMediaItem(
val clearKeys = drm?.takeIf { it.type.equals("clearkey", ignoreCase = true) }?.keys.orEmpty() val clearKeys = drm?.takeIf { it.type.equals("clearkey", ignoreCase = true) }?.keys.orEmpty()
if (clearKeys.isNotEmpty()) { if (clearKeys.isNotEmpty()) {
val licenseJson = buildClearKeyLicenseJson(clearKeys) val licenseJson = buildClearKeyLicenseJson(clearKeys)
val licenseUri = builder
"data:application/json;base64," + .setTag(ClearKeyLicenseTag(licenseJson))
android.util.Base64.encodeToString( .setDrmConfiguration(
licenseJson.toByteArray(Charsets.UTF_8), MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID).build(),
android.util.Base64.NO_WRAP, )
)
builder.setDrmConfiguration(
MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID)
.setLicenseUri(licenseUri)
.setForceDefaultLicenseUri(true)
.build(),
)
} }
return builder.build() return builder.build()
} }
private fun buildClearKeyLicenseJson(keys: List<nl.vonas.mediacluster.tv.data.ClearKeyEntry>): String { private fun buildClearKeyLicenseJson(keys: List<ClearKeyEntry>): String {
val arr = org.json.JSONArray() val arr = org.json.JSONArray()
for (k in keys) { for (k in keys) {
arr.put( arr.put(
@ -299,8 +317,15 @@ private fun buildClearKeyLicenseJson(keys: List<nl.vonas.mediacluster.tv.data.Cl
/** ClearKey JSON uses URL-safe base64 without padding. */ /** ClearKey JSON uses URL-safe base64 without padding. */
private fun hexToBase64Url(hex: String): String { private fun hexToBase64Url(hex: String): String {
val clean = hex.trim().removePrefix("0x").replace(Regex("[\\s:\\-]"), "").lowercase() val clean = hex.trim()
require(clean.length % 2 == 0) { "ongeldige hex key" } .removePrefix("0x")
.removePrefix("0X")
.replace("-", "")
.replace(Regex("[\\s:]"), "")
.lowercase()
require(clean.length == 32 && clean.all { it in "0123456789abcdef" }) {
"ongeldige ClearKey hex (verwacht 32 tekens): ${hex.take(16)}…"
}
val bytes = ByteArray(clean.length / 2) { i -> val bytes = ByteArray(clean.length / 2) { i ->
clean.substring(i * 2, i * 2 + 2).toInt(16).toByte() clean.substring(i * 2, i * 2 + 2).toInt(16).toByte()
} }