diff --git a/WEB_DRM_SUPPORT.md b/WEB_DRM_SUPPORT.md
new file mode 100644
index 00000000..8cd5c93d
--- /dev/null
+++ b/WEB_DRM_SUPPORT.md
@@ -0,0 +1,143 @@
+# Web DRM Support (Widevine)
+
+This adds Widevine DRM support for web platforms (WASM/JS) using dash.js for DASH manifest parsing and EME for license acquisition.
+
+## Features
+
+- Widevine DRM playback on web browsers (Chrome, Firefox, Edge)
+- DASH/MPD manifest support via dash.js
+- Custom license headers for authentication
+- Sample app with DRM testing UI
+
+## Installation
+
+### 1. Add dash.js to your HTML
+
+In your `src/webMain/resources/index.html`, add the dash.js script before your app:
+
+```html
+
+
+
+
+
+
+```
+
+### 2. Copy drm-helper.js
+
+Copy `mediaplayer/src/webMain/resources/drm-helper.js` to your project's `src/webMain/resources/` folder.
+
+### 3. Enable HTTPS (Required for DRM)
+
+EME requires a secure context. Create `webpack.config.d/https.config.js`:
+
+```javascript
+config.devServer = config.devServer || {};
+config.devServer.server = 'https';
+config.devServer.host = '0.0.0.0';
+```
+
+## Usage
+
+### Basic Widevine Playback
+
+```kotlin
+import io.github.kdroidfilter.composemediaplayer.DrmConfiguration
+import io.github.kdroidfilter.composemediaplayer.DrmType
+import io.github.kdroidfilter.composemediaplayer.rememberVideoPlayerState
+
+@Composable
+fun DrmVideoPlayer() {
+ val playerState = rememberVideoPlayerState()
+
+ // Create DRM configuration
+ val drmConfig = DrmConfiguration(
+ drmType = DrmType.WIDEVINE,
+ licenseUrl = "https://your-license-server.com/acquire",
+ licenseHeaders = mapOf(
+ "Authorization" to "Bearer your-token"
+ )
+ )
+
+ // Open DRM-protected content
+ LaunchedEffect(Unit) {
+ playerState.openUri(
+ uri = "https://example.com/content.mpd",
+ drmConfiguration = drmConfig
+ )
+ }
+
+ VideoPlayerSurface(
+ playerState = playerState,
+ modifier = Modifier.fillMaxSize()
+ )
+}
+```
+
+### With Custom Headers (AxDRM Example)
+
+```kotlin
+val drmConfig = DrmConfiguration(
+ drmType = DrmType.WIDEVINE,
+ licenseUrl = "https://drm-widevine-licensing.axtest.net/AcquireLicense",
+ licenseHeaders = mapOf(
+ "X-AxDRM-Message" to "eyJhbGciOiJIUzI1NiIs..." // Your JWT token
+ )
+)
+
+playerState.openUri(
+ uri = "https://media.axprod.net/TestVectors/Cmaf/protected_1080p_h264_cbcs/manifest.mpd",
+ drmConfiguration = drmConfig
+)
+```
+
+### Non-DRM Playback (Unchanged)
+
+For regular content without DRM, use the standard API:
+
+```kotlin
+playerState.openUri("https://example.com/video.mp4")
+```
+
+## DrmConfiguration Options
+
+| Parameter | Type | Description |
+|-----------|------|-------------|
+| `drmType` | `DrmType` | DRM system: `WIDEVINE`, `PLAYREADY`, or `CLEARKEY` |
+| `licenseUrl` | `String` | License server URL |
+| `licenseHeaders` | `Map` | Custom HTTP headers for license requests |
+
+## Platform Support
+
+| Platform | Status |
+|----------|--------|
+| Web (WASM/JS) | ✅ Widevine supported |
+| Android | 🔜 Planned (ExoPlayer DRM) |
+| iOS | 🔜 Planned (FairPlay) |
+| Desktop | 🔜 Planned |
+
+## Troubleshooting
+
+### "No supported version of EME detected"
+- Make sure you're accessing the page via HTTPS (or localhost)
+- Check that the browser supports Widevine
+
+### License request fails
+- Verify the license URL is correct
+- Check that custom headers are properly formatted
+- Ensure CORS is configured on the license server
+
+### Video doesn't play
+- Confirm the manifest URL is a valid DASH/MPD file
+- Check browser console for `[DRM]` log messages
+- Verify the content is encrypted with Widevine
+
+## Test Streams
+
+AxDRM test content for development:
+
+```
+URL: https://media.axprod.net/TestVectors/Cmaf/protected_1080p_h264_cbcs/manifest.mpd
+License: https://drm-widevine-licensing.axtest.net/AcquireLicense
+```
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/DrmConfiguration.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/DrmConfiguration.kt
new file mode 100644
index 00000000..2c472ead
--- /dev/null
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/DrmConfiguration.kt
@@ -0,0 +1,74 @@
+package io.github.kdroidfilter.composemediaplayer
+
+/**
+ * DRM type enumeration for supported DRM systems.
+ * Currently only Widevine is supported for web platform.
+ * ClearKey is included for testing purposes.
+ */
+enum class DrmType(val keySystem: String) {
+ WIDEVINE("com.widevine.alpha"),
+ PLAYREADY("com.microsoft.playready"),
+ CLEARKEY("org.w3.clearkey")
+}
+
+/**
+ * Configuration for DRM-protected content playback.
+ *
+ * @property drmType The DRM system to use
+ * @property licenseUrl The URL to request licenses from
+ * @property licenseHeaders Optional HTTP headers to include in license requests
+ * @property initDataTypes Initialization data types (e.g., "cenc", "keyids", "webm")
+ */
+data class DrmConfiguration(
+ val drmType: DrmType,
+ val licenseUrl: String,
+ val licenseHeaders: Map = emptyMap(),
+ val initDataTypes: List = listOf("cenc", "keyids", "webm")
+) {
+ companion object {
+ /**
+ * Creates a Widevine DRM configuration.
+ *
+ * @param licenseUrl The Widevine license server URL
+ * @param headers Optional headers for license requests
+ */
+ fun widevine(
+ licenseUrl: String,
+ headers: Map = emptyMap()
+ ) = DrmConfiguration(
+ drmType = DrmType.WIDEVINE,
+ licenseUrl = licenseUrl,
+ licenseHeaders = headers
+ )
+
+ /**
+ * Creates a ClearKey DRM configuration.
+ *
+ * @param licenseUrl The ClearKey license server URL
+ * @param headers Optional headers for license requests
+ */
+ fun clearKey(
+ licenseUrl: String,
+ headers: Map = emptyMap()
+ ) = DrmConfiguration(
+ drmType = DrmType.CLEARKEY,
+ licenseUrl = licenseUrl,
+ licenseHeaders = headers
+ )
+
+ /**
+ * Creates a PlayReady DRM configuration.
+ *
+ * @param licenseUrl The PlayReady license server URL
+ * @param headers Optional headers for license requests
+ */
+ fun playReady(
+ licenseUrl: String,
+ headers: Map = emptyMap()
+ ) = DrmConfiguration(
+ drmType = DrmType.PLAYREADY,
+ licenseUrl = licenseUrl,
+ licenseHeaders = headers
+ )
+ }
+}
diff --git a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt
index 9174d939..4cd97a25 100644
--- a/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt
+++ b/mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.kt
@@ -103,6 +103,27 @@ interface VideoPlayerState {
* Opens a video file or URL for playback.
*/
fun openUri(uri: String, initializeplayerState: InitialPlayerState = InitialPlayerState.PLAY)
+
+ /**
+ * Opens a video URL with DRM configuration for protected content.
+ *
+ * On web platforms, this will initialize EME (Encrypted Media Extensions) with the
+ * specified DRM configuration. On other platforms, DRM may not be supported and
+ * the drmConfiguration parameter will be ignored.
+ *
+ * @param uri The media URI to open
+ * @param drmConfiguration The DRM configuration (license URL, headers, etc.)
+ * @param initializeplayerState Controls whether playback should start automatically
+ */
+ fun openUri(
+ uri: String,
+ drmConfiguration: DrmConfiguration?,
+ initializeplayerState: InitialPlayerState = InitialPlayerState.PLAY
+ ) {
+ // Default implementation ignores DRM config - platforms override as needed
+ openUri(uri, initializeplayerState)
+ }
+
fun openFile(file: PlatformFile, initializeplayerState: InitialPlayerState = InitialPlayerState.PLAY)
// Error handling
diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt
index bbc6d0b4..b7c45b66 100644
--- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt
+++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerState.web.kt
@@ -51,6 +51,10 @@ open class DefaultVideoPlayerState: VideoPlayerState {
// Source URI of the current media
private var _sourceUri by mutableStateOf(null)
val sourceUri: String? get() = _sourceUri
+
+ // DRM configuration for protected content
+ private var _drmConfiguration by mutableStateOf(null)
+ val drmConfiguration: DrmConfiguration? get() = _drmConfiguration
// Playback state properties
private var _isPlaying by mutableStateOf(false)
@@ -232,12 +236,31 @@ open class DefaultVideoPlayerState: VideoPlayerState {
* @param initializeplayerState Controls whether playback should start automatically after opening
*/
override fun openUri(uri: String, initializeplayerState: InitialPlayerState) {
+ openUri(uri, null, initializeplayerState)
+ }
+
+ /**
+ * Opens a media source from the given URI with optional DRM configuration.
+ *
+ * On web platforms, this will initialize EME (Encrypted Media Extensions) with the
+ * specified DRM configuration when provided.
+ *
+ * @param uri The URI of the media to open
+ * @param drmConfiguration The DRM configuration for protected content, or null for unprotected content
+ * @param initializeplayerState Controls whether playback should start automatically after opening
+ */
+ override fun openUri(
+ uri: String,
+ drmConfiguration: DrmConfiguration?,
+ initializeplayerState: InitialPlayerState
+ ) {
playerScope.coroutineContext.cancelChildren()
// Store the URI for potential replay after stop
lastUri = uri
_sourceUri = uri
+ _drmConfiguration = drmConfiguration
_hasMedia = true
_isLoading = true // Set initial loading state
_error = null
@@ -299,6 +322,7 @@ open class DefaultVideoPlayerState: VideoPlayerState {
override fun stop() {
_isPlaying = false
_sourceUri = null
+ _drmConfiguration = null
_hasMedia = false
_isLoading = false
sliderPos = 0.0f
diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt
index 4160de1e..fc4a7325 100644
--- a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt
+++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/VideoPlayerSurfaceImpl.kt
@@ -1,3 +1,5 @@
+@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class)
+
package io.github.kdroidfilter.composemediaplayer
import androidx.compose.foundation.background
@@ -15,6 +17,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.ContentScale
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
+import io.github.kdroidfilter.composemediaplayer.jsinterop.DrmHelper
import io.github.kdroidfilter.composemediaplayer.jsinterop.MediaError
import io.github.kdroidfilter.composemediaplayer.subtitle.ComposeSubtitleLayer
import io.github.kdroidfilter.composemediaplayer.util.FullScreenLayout
@@ -22,11 +25,14 @@ import io.github.kdroidfilter.composemediaplayer.util.toTimeMs
import kotlinx.browser.document
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.await
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.w3c.dom.HTMLElement
import org.w3c.dom.HTMLVideoElement
import org.w3c.dom.events.Event
+import kotlin.js.JsAny
+import kotlin.js.toJsString
import kotlin.math.abs
internal val webVideoLogger = Logger.withTag("WebVideoPlayerSurface").apply { Logger.setMinSeverity(Severity.Warn) }
@@ -583,20 +589,64 @@ internal fun VideoPlayerEffects(
}
}
- // Handle source change effect
-
+ // Handle source change effect with DRM support
if (playerState is DefaultVideoPlayerState) {
- LaunchedEffect(videoElement, playerState.sourceUri) {
+ // Track DRM handler for cleanup
+ var drmCleanup by remember { mutableStateOf<(() -> Unit)?>(null) }
+
+ LaunchedEffect(videoElement, playerState.sourceUri, playerState.drmConfiguration) {
videoElement?.let { video ->
val sourceUri = playerState.sourceUri ?: ""
if (sourceUri.isNotEmpty()) {
playerState.clearError()
- video.src = sourceUri
- video.load()
- if (playerState.isPlaying) video.safePlay() else video.safePause()
+
+ // Clean up previous DRM session if any
+ drmCleanup?.invoke()
+ drmCleanup = null
+
+ // Check if this is DASH content with DRM
+ val drmConfig = playerState.drmConfiguration
+ val isDashWithDrm = drmConfig != null && isDrmHelperAvailable() &&
+ DrmHelper.isDashUrl(sourceUri.toJsString())
+
+ // Initialize DRM if configuration is provided
+ if (drmConfig != null) {
+ webVideoLogger.d { "Initializing DRM for ${drmConfig.drmType.name}" }
+ try {
+ // Call JavaScript DrmHelper to initialize DRM
+ // For DASH, this also sets up dash.js which handles the source
+ val result = initializeDrm(video, drmConfig, sourceUri)
+ if (result != null) {
+ drmCleanup = result
+ webVideoLogger.i { "DRM initialized successfully" }
+ } else {
+ webVideoLogger.w { "DRM initialization returned null, playback may fail" }
+ }
+ } catch (e: Exception) {
+ webVideoLogger.e(e) { "DRM initialization failed: ${e.message}" }
+ playerState.setError(VideoPlayerError.SourceError("DRM error: ${e.message}"))
+ }
+ }
+
+ // For DASH with DRM, dash.js handles the source, so don't set video.src
+ if (!isDashWithDrm) {
+ video.src = sourceUri
+ video.load()
+ }
+ // dash.js auto-plays, for non-dash respect player state
+ if (!isDashWithDrm) {
+ if (playerState.isPlaying) video.safePlay() else video.safePause()
+ }
}
}
}
+
+ // Clean up DRM on dispose
+ DisposableEffect(Unit) {
+ onDispose {
+ drmCleanup?.invoke()
+ }
+ }
}
// Handle play/pause
LaunchedEffect(videoElement, playerState.isPlaying) {
@@ -747,3 +797,118 @@ internal fun VideoVolumeAndSpeedEffects(
}
}
}
+
+// =====================================================
+// DRM Helper Functions
+// =====================================================
+
+/**
+ * Initialize DRM for the video element.
+ * For DASH URLs (.mpd), uses dash.js for both parsing and DRM.
+ * For other URLs, uses native EME API.
+ *
+ * @param video The HTMLVideoElement to attach DRM to
+ * @param config The DRM configuration
+ * @param sourceUri The source URL (to detect DASH)
+ * @return A cleanup function to call when disposing, or null if DRM setup failed
+ */
+internal suspend fun initializeDrm(
+ video: HTMLVideoElement,
+ config: DrmConfiguration,
+ sourceUri: String
+): (() -> Unit)? {
+ return try {
+ // Check if DrmHelper is available
+ if (!isDrmHelperAvailable()) {
+ webVideoLogger.w { "DrmHelper not available - drm-helper.js may not be loaded" }
+ return null
+ }
+
+ val licenseUrl = config.licenseUrl.toJsString()
+ val headers = buildLicenseHeadersObject(config.licenseHeaders)
+
+ // Check if this is a DASH URL
+ val isDash = DrmHelper.isDashUrl(sourceUri.toJsString())
+
+ if (isDash) {
+ // Use dash.js for DASH content - handles both parsing and DRM
+ webVideoLogger.d { "Using dash.js for DASH content with ${config.drmType.name} DRM" }
+
+ val result = DrmHelper.setupDash(
+ video,
+ sourceUri.toJsString(),
+ config.drmType.name.toJsString(),
+ licenseUrl,
+ headers
+ )
+
+ if (result != null) {
+ webVideoLogger.i("dash.js DRM setup completed successfully")
+ val cleanupFn = result.cleanup
+ // Return cleanup function - also prevents setting video.src directly
+ return { cleanupFn() }
+ } else {
+ webVideoLogger.e { "dash.js not loaded or setup failed" }
+ return null
+ }
+ } else {
+ // Use native EME for non-DASH content
+ val keySystem = config.drmType.keySystem.toJsString()
+ webVideoLogger.d { "Calling DrmHelper.setup for ${config.drmType.keySystem}" }
+
+ val result: io.github.kdroidfilter.composemediaplayer.jsinterop.DrmSetupResult =
+ DrmHelper.setup(video, keySystem, licenseUrl, headers).await()
+
+ webVideoLogger.i("EME DRM setup completed successfully")
+
+ // Return cleanup function
+ val cleanupFn = result.cleanup
+ { cleanupFn() }
+ }
+
+ } catch (e: Exception) {
+ webVideoLogger.e(e) { "DRM initialization error: ${e.message}" }
+ null
+ }
+}
+
+/**
+ * Check if DrmHelper JavaScript object is available.
+ */
+private fun isDrmHelperAvailable(): Boolean {
+ return try {
+ DrmHelper.isSupported()
+ true
+ } catch (e: Exception) {
+ false
+ }
+}
+
+/**
+ * Build license headers object for JavaScript.
+ */
+private fun buildLicenseHeadersObject(headers: Map): JsAny? {
+ if (headers.isEmpty()) {
+ return null
+ }
+
+ // For WASM, we need to create JS object via DrmHelper.parseJson
+ // (can't call JSON.parse directly from WASM)
+ val jsonEntries = headers.entries.joinToString(",") { (key, value) ->
+ "\"${escapeJsonString(key)}\":\"${escapeJsonString(value)}\""
+ }
+ val jsonString = "{$jsonEntries}"
+
+ return DrmHelper.parseJson(jsonString.toJsString())
+}
+
+/**
+ * Escape special characters for JSON string.
+ */
+private fun escapeJsonString(s: String): String {
+ return s.replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+ .replace("\n", "\\n")
+ .replace("\r", "\\r")
+ .replace("\t", "\\t")
+}
diff --git a/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/EncryptedMediaExtensions.kt b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/EncryptedMediaExtensions.kt
new file mode 100644
index 00000000..5cc683be
--- /dev/null
+++ b/mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/EncryptedMediaExtensions.kt
@@ -0,0 +1,164 @@
+@file:Suppress("unused", "UNUSED_PARAMETER")
+@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class)
+
+package io.github.kdroidfilter.composemediaplayer.jsinterop
+
+import org.khronos.webgl.ArrayBuffer
+import org.w3c.dom.HTMLVideoElement
+import org.w3c.dom.events.Event
+import org.w3c.dom.events.EventTarget
+import kotlin.js.JsAny
+import kotlin.js.JsString
+import kotlin.js.Promise
+
+// =====================================================
+// Encrypted Media Extensions (EME) API Bindings
+// For Kotlin/WASM JS Interop
+// =====================================================
+
+/**
+ * Provides access to a Key System for decryption.
+ */
+external class MediaKeySystemAccess : JsAny {
+ val keySystem: JsString
+ fun getConfiguration(): JsAny
+ fun createMediaKeys(): Promise
+}
+
+/**
+ * Represents a set of keys that an HTMLMediaElement can use for decryption.
+ */
+external class MediaKeys : JsAny {
+ fun createSession(sessionType: JsString): MediaKeySession
+ fun createSession(): MediaKeySession
+}
+
+/**
+ * Represents a context for message exchange with a CDM.
+ */
+external class MediaKeySession : EventTarget {
+ val sessionId: JsString
+ val expiration: Double
+ val closed: Promise
+ val keyStatuses: MediaKeyStatusMap
+
+ fun generateRequest(initDataType: JsString, initData: ArrayBuffer): Promise
+ fun load(sessionId: JsString): Promise
+ fun update(response: ArrayBuffer): Promise
+ fun close(): Promise
+ fun remove(): Promise
+}
+
+/**
+ * Read-only map of media key statuses.
+ */
+external class MediaKeyStatusMap : JsAny {
+ val size: Int
+ fun has(keyId: ArrayBuffer): Boolean
+ fun get(keyId: ArrayBuffer): JsString?
+}
+
+/**
+ * Event containing a message from the CDM.
+ */
+external class MediaKeyMessageEvent : Event {
+ val messageType: JsString
+ val message: ArrayBuffer
+}
+
+/**
+ * Event fired when encrypted content is encountered.
+ */
+external class MediaEncryptedEvent : Event {
+ val initDataType: JsString
+ val initData: ArrayBuffer?
+}
+
+// =====================================================
+// DRM Setup Result from JavaScript
+// =====================================================
+
+/**
+ * Result object returned by DrmHelper.setup()
+ */
+external interface DrmSetupResult : JsAny {
+ val mediaKeys: MediaKeys
+ val cleanup: () -> Unit
+}
+
+// =====================================================
+// DrmHelper Object (defined in drm-helper.js)
+// =====================================================
+
+/**
+ * External declaration for the DrmHelper JavaScript object.
+ * Must be loaded before use via drm-helper.js
+ */
+@JsName("DrmHelper")
+external object DrmHelper : JsAny {
+ /**
+ * Check if EME is supported in the current browser.
+ */
+ fun isSupported(): Boolean
+
+ /**
+ * Get key system string for DRM type.
+ * @param drmType - "WIDEVINE", "PLAYREADY", or "CLEARKEY"
+ */
+ fun getKeySystem(drmType: JsString): JsString
+
+ /**
+ * Setup DRM for a video element with full workflow.
+ * This is the main entry point for DRM initialization.
+ *
+ * @param video - The HTMLVideoElement to attach DRM to
+ * @param keySystem - e.g., "com.widevine.alpha"
+ * @param licenseUrl - The license server URL
+ * @param licenseHeaders - Additional headers for license requests (can be null)
+ * @return Promise that resolves to DrmSetupResult with cleanup function
+ */
+ fun setup(
+ video: HTMLVideoElement,
+ keySystem: JsString,
+ licenseUrl: JsString,
+ licenseHeaders: JsAny?
+ ): Promise
+
+ /**
+ * Setup DASH playback with DRM using dash.js.
+ * This handles both DASH manifest parsing and DRM.
+ *
+ * @param video - The HTMLVideoElement
+ * @param url - The DASH manifest URL (.mpd)
+ * @param drmType - "WIDEVINE", "PLAYREADY", or "CLEARKEY"
+ * @param licenseUrl - The license server URL
+ * @param licenseHeaders - Additional headers for license requests (can be null)
+ * @return DashSetupResult with player and cleanup function, or null if dash.js not loaded
+ */
+ fun setupDash(
+ video: HTMLVideoElement,
+ url: JsString,
+ drmType: JsString,
+ licenseUrl: JsString,
+ licenseHeaders: JsAny?
+ ): DashSetupResult?
+
+ /**
+ * Check if URL is a DASH manifest.
+ */
+ fun isDashUrl(url: JsString): Boolean
+
+ /**
+ * Parse JSON string to JS object.
+ * Helper for WASM which can't call JSON.parse directly.
+ */
+ fun parseJson(jsonString: JsString): JsAny?
+}
+
+/**
+ * Result object returned by DrmHelper.setupDash()
+ */
+external interface DashSetupResult : JsAny {
+ val player: JsAny
+ val cleanup: () -> Unit
+}
diff --git a/mediaplayer/src/webMain/resources/drm-helper.js b/mediaplayer/src/webMain/resources/drm-helper.js
new file mode 100644
index 00000000..64dafb1f
--- /dev/null
+++ b/mediaplayer/src/webMain/resources/drm-helper.js
@@ -0,0 +1,374 @@
+/**
+ * DRM Helper Functions for ComposeMediaPlayer
+ * These functions bridge Kotlin/WASM with EME browser APIs
+ */
+
+// Global namespace for DRM functions
+window.DrmHelper = window.DrmHelper || {};
+
+/**
+ * Check if EME is supported
+ */
+window.DrmHelper.isSupported = function() {
+ return typeof navigator !== 'undefined' &&
+ typeof navigator.requestMediaKeySystemAccess === 'function';
+};
+
+/**
+ * Request MediaKeySystemAccess
+ * @param {string} keySystem - The key system identifier (e.g., "com.widevine.alpha")
+ * @param {string} configJson - JSON string of MediaKeySystemConfiguration array
+ * @returns {Promise}
+ */
+window.DrmHelper.requestAccess = function(keySystem, configJson) {
+ if (!this.isSupported()) {
+ return Promise.resolve(null);
+ }
+
+ try {
+ var config = JSON.parse(configJson);
+ return navigator.requestMediaKeySystemAccess(keySystem, config);
+ } catch(e) {
+ console.error('DrmHelper.requestAccess failed:', e);
+ return Promise.reject(e);
+ }
+};
+
+/**
+ * Create MediaKeys from MediaKeySystemAccess
+ * @param {MediaKeySystemAccess} access
+ * @returns {Promise}
+ */
+window.DrmHelper.createMediaKeys = function(access) {
+ return access.createMediaKeys();
+};
+
+/**
+ * Set MediaKeys on video element
+ * @param {HTMLVideoElement} video
+ * @param {MediaKeys} mediaKeys
+ * @returns {Promise}
+ */
+window.DrmHelper.setMediaKeys = function(video, mediaKeys) {
+ return video.setMediaKeys(mediaKeys);
+};
+
+/**
+ * Create a media key session
+ * @param {MediaKeys} mediaKeys
+ * @param {string} sessionType - "temporary" or "persistent-license"
+ * @returns {MediaKeySession}
+ */
+window.DrmHelper.createSession = function(mediaKeys, sessionType) {
+ return mediaKeys.createSession(sessionType || 'temporary');
+};
+
+/**
+ * Generate a license request
+ * @param {MediaKeySession} session
+ * @param {string} initDataType
+ * @param {ArrayBuffer} initData
+ * @returns {Promise}
+ */
+window.DrmHelper.generateRequest = function(session, initDataType, initData) {
+ return session.generateRequest(initDataType, initData);
+};
+
+/**
+ * Update session with license
+ * @param {MediaKeySession} session
+ * @param {ArrayBuffer} license
+ * @returns {Promise}
+ */
+window.DrmHelper.updateSession = function(session, license) {
+ return session.update(license);
+};
+
+/**
+ * Close session
+ * @param {MediaKeySession} session
+ * @returns {Promise}
+ */
+window.DrmHelper.closeSession = function(session) {
+ if (session && typeof session.close === 'function') {
+ return session.close();
+ }
+ return Promise.resolve();
+};
+
+/**
+ * Fetch license from server
+ * @param {string} licenseUrl
+ * @param {ArrayBuffer} message - The license request message
+ * @param {Object} headers - Additional headers for the request
+ * @returns {Promise}
+ */
+window.DrmHelper.fetchLicense = function(licenseUrl, message, headers) {
+ var fetchOptions = {
+ method: 'POST',
+ body: message,
+ headers: Object.assign({ 'Content-Type': 'application/octet-stream' }, headers || {})
+ };
+
+ return fetch(licenseUrl, fetchOptions)
+ .then(function(response) {
+ if (!response.ok) {
+ throw new Error('License request failed: ' + response.status + ' ' + response.statusText);
+ }
+ return response.arrayBuffer();
+ });
+};
+
+/**
+ * Setup DRM for a video element with full workflow
+ * @param {HTMLVideoElement} video
+ * @param {string} keySystem - e.g., "com.widevine.alpha"
+ * @param {string} licenseUrl
+ * @param {Object} licenseHeaders
+ * @returns {Promise<{mediaKeys: MediaKeys, cleanup: Function}>}
+ */
+window.DrmHelper.setup = function(video, keySystem, licenseUrl, licenseHeaders) {
+ var self = this;
+ var mediaKeys = null;
+ var sessions = [];
+
+ var config = [{
+ initDataTypes: ['cenc', 'keyids', 'webm'],
+ videoCapabilities: [
+ { contentType: 'video/mp4; codecs="avc1.42E01E"' },
+ { contentType: 'video/mp4; codecs="avc1.4D401E"' },
+ { contentType: 'video/mp4; codecs="avc1.64001E"' },
+ { contentType: 'video/webm; codecs="vp8"' },
+ { contentType: 'video/webm; codecs="vp9"' }
+ ],
+ audioCapabilities: [
+ { contentType: 'audio/mp4; codecs="mp4a.40.2"' },
+ { contentType: 'audio/webm; codecs="opus"' },
+ { contentType: 'audio/webm; codecs="vorbis"' }
+ ],
+ distinctiveIdentifier: 'optional',
+ persistentState: 'optional',
+ sessionTypes: ['temporary']
+ }];
+
+ function handleEncrypted(event) {
+ console.log('[DRM] Encrypted event received:', event.initDataType);
+
+ if (!mediaKeys) {
+ console.error('[DRM] MediaKeys not available');
+ return;
+ }
+
+ var session = mediaKeys.createSession('temporary');
+ sessions.push(session);
+
+ session.addEventListener('message', function(messageEvent) {
+ console.log('[DRM] Session message:', messageEvent.messageType);
+
+ self.fetchLicense(licenseUrl, messageEvent.message, licenseHeaders)
+ .then(function(license) {
+ console.log('[DRM] License received, size:', license.byteLength);
+ return session.update(license);
+ })
+ .then(function() {
+ console.log('[DRM] License applied successfully');
+ })
+ .catch(function(error) {
+ console.error('[DRM] License acquisition failed:', error);
+ });
+ });
+
+ session.addEventListener('keystatuseschange', function() {
+ console.log('[DRM] Key status changed');
+ session.keyStatuses.forEach(function(status, keyId) {
+ console.log('[DRM] Key status:', status);
+ });
+ });
+
+ session.generateRequest(event.initDataType, event.initData)
+ .then(function() {
+ console.log('[DRM] License request generated');
+ })
+ .catch(function(error) {
+ console.error('[DRM] generateRequest failed:', error);
+ });
+ }
+
+ return navigator.requestMediaKeySystemAccess(keySystem, config)
+ .then(function(access) {
+ console.log('[DRM] Got MediaKeySystemAccess for:', keySystem);
+ return access.createMediaKeys();
+ })
+ .then(function(keys) {
+ console.log('[DRM] MediaKeys created');
+ mediaKeys = keys;
+ return video.setMediaKeys(keys);
+ })
+ .then(function() {
+ console.log('[DRM] MediaKeys attached to video');
+ video.addEventListener('encrypted', handleEncrypted);
+
+ return {
+ mediaKeys: mediaKeys,
+ cleanup: function() {
+ video.removeEventListener('encrypted', handleEncrypted);
+ sessions.forEach(function(s) {
+ s.close().catch(function(e) {
+ console.log('[DRM] Session close error:', e);
+ });
+ });
+ sessions = [];
+ }
+ };
+ });
+};
+
+/**
+ * Get key system string for DRM type
+ * @param {string} drmType - "WIDEVINE", "PLAYREADY", or "CLEARKEY"
+ * @returns {string}
+ */
+window.DrmHelper.getKeySystem = function(drmType) {
+ switch((drmType || '').toUpperCase()) {
+ case 'WIDEVINE': return 'com.widevine.alpha';
+ case 'PLAYREADY': return 'com.microsoft.playready';
+ case 'CLEARKEY': return 'org.w3.clearkey';
+ default: return 'com.widevine.alpha';
+ }
+};
+
+/**
+ * Setup DASH playback with DRM using dash.js
+ * Matches the reference implementation from dashif.org
+ * @param {HTMLVideoElement} video
+ * @param {string} url - The DASH manifest URL (.mpd)
+ * @param {string} drmType - "WIDEVINE", "PLAYREADY", or "CLEARKEY"
+ * @param {string} licenseUrl
+ * @param {Object} licenseHeaders
+ * @returns {{player: Object, cleanup: Function}}
+ */
+window.DrmHelper.setupDash = function(video, url, drmType, licenseUrl, licenseHeaders) {
+ // Check if dash.js is available
+ if (typeof dashjs === 'undefined') {
+ console.error('[DRM] dash.js not loaded! Include it before drm-helper.js');
+ return null;
+ }
+
+ // Convert JsString to native string if needed
+ var urlStr = (typeof url === 'object' && url.toString) ? url.toString() : String(url);
+ var licenseUrlStr = (typeof licenseUrl === 'object' && licenseUrl.toString) ? licenseUrl.toString() : String(licenseUrl);
+ var drmTypeStr = (typeof drmType === 'object' && drmType.toString) ? drmType.toString() : String(drmType);
+
+ console.log('[DRM] setupDash called:', {
+ url: urlStr,
+ drmType: drmTypeStr,
+ licenseUrl: licenseUrlStr,
+ hasHeaders: licenseHeaders ? Object.keys(licenseHeaders).length : 0
+ });
+
+ // Build protection data exactly like the reference implementation
+ var keySystem = this.getKeySystem(drmTypeStr);
+ var protData = {};
+
+ protData[keySystem] = {
+ "serverURL": licenseUrlStr,
+ "priority": 0
+ };
+
+ // Add headers if provided
+ if (licenseHeaders && typeof licenseHeaders === 'object') {
+ var headerKeys = Object.keys(licenseHeaders);
+ if (headerKeys.length > 0) {
+ protData[keySystem].httpRequestHeaders = {};
+ for (var i = 0; i < headerKeys.length; i++) {
+ var hKey = headerKeys[i];
+ var hVal = licenseHeaders[hKey];
+ // Convert to native string
+ protData[keySystem].httpRequestHeaders[hKey] =
+ (typeof hVal === 'object' && hVal.toString) ? hVal.toString() : String(hVal);
+ }
+ }
+ }
+
+ console.log('[DRM] Protection data:', JSON.stringify(protData, null, 2));
+
+ // Create player
+ var player = dashjs.MediaPlayer().create();
+
+ // Initialize and set protection (same order as reference)
+ player.initialize(video, urlStr, true);
+ player.setProtectionData(protData);
+
+ // Log events for debugging
+ player.on(dashjs.MediaPlayer.events.ERROR, function(e) {
+ console.error('[DRM] dash.js error:', e);
+ });
+
+ player.on(dashjs.MediaPlayer.events.PROTECTION_CREATED, function(e) {
+ console.log('[DRM] Protection created');
+ });
+
+ player.on(dashjs.MediaPlayer.events.KEY_SYSTEM_SELECTED, function(e) {
+ console.log('[DRM] Key system selected:', e.data ? e.data.keySystem.systemString : 'unknown');
+ });
+
+ player.on(dashjs.MediaPlayer.events.LICENSE_REQUEST_COMPLETE, function(e) {
+ if (e.error) {
+ console.error('[DRM] License request failed:', e.error);
+ } else {
+ console.log('[DRM] License request complete');
+ }
+ });
+
+ player.on(dashjs.MediaPlayer.events.KEY_SESSION_CREATED, function(e) {
+ console.log('[DRM] Key session created');
+ });
+
+ player.on(dashjs.MediaPlayer.events.KEY_STATUSES_CHANGED, function(e) {
+ console.log('[DRM] Key statuses changed');
+ });
+
+ player.on(dashjs.MediaPlayer.events.PLAYBACK_STARTED, function(e) {
+ console.log('[DRM] Playback started!');
+ });
+
+ player.on(dashjs.MediaPlayer.events.CAN_PLAY, function(e) {
+ console.log('[DRM] Can play');
+ });
+
+ return {
+ player: player,
+ cleanup: function() {
+ console.log('[DRM] Cleaning up dash.js player');
+ try {
+ player.reset();
+ } catch(e) {
+ console.log('[DRM] Player reset error:', e);
+ }
+ }
+ };
+};
+
+/**
+ * Check if URL is a DASH manifest
+ * @param {string} url
+ * @returns {boolean}
+ */
+window.DrmHelper.isDashUrl = function(url) {
+ return url && (url.toLowerCase().endsWith('.mpd') || url.includes('.mpd?'));
+};
+
+/**
+ * Parse JSON string to object (helper for WASM which can't call JSON.parse directly)
+ */
+window.DrmHelper.parseJson = function(jsonString) {
+ try {
+ var str = (typeof jsonString === 'object' && jsonString.toString) ? jsonString.toString() : String(jsonString);
+ return JSON.parse(str);
+ } catch(e) {
+ console.error('[DRM] JSON parse error:', e);
+ return null;
+ }
+};
+
+console.log('[DRM] DrmHelper loaded (with dash.js support)');
diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt
index d752dd46..8b590f0c 100644
--- a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt
+++ b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/PlayerComponents.kt
@@ -399,6 +399,130 @@ fun VideoUrlInput(
)
}
+@Composable
+fun DrmControlsCard(
+ licenseUrl: String,
+ onLicenseUrlChange: (String) -> Unit,
+ drmHeaders: String,
+ onDrmHeadersChange: (String) -> Unit,
+ drmEnabled: Boolean,
+ onDrmEnabledChange: (Boolean) -> Unit,
+ drmType: String,
+ onDrmTypeChange: (String) -> Unit,
+ onLoadTestStream: (() -> Unit)? = null
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f)
+ ),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "🔐 DRM Settings",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ Switch(
+ checked = drmEnabled,
+ onCheckedChange = onDrmEnabledChange
+ )
+ }
+
+ AnimatedVisibility(visible = drmEnabled) {
+ Column {
+ Spacer(modifier = Modifier.height(12.dp))
+
+ // DRM Type selection
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text("Type: ", style = MaterialTheme.typography.bodyMedium)
+ listOf("WIDEVINE", "PLAYREADY", "CLEARKEY").forEach { type ->
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(
+ selected = drmType == type,
+ onClick = { onDrmTypeChange(type) }
+ )
+ Text(
+ text = type,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.clickable { onDrmTypeChange(type) }
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Show coming soon for unsupported DRM types
+ if (drmType == "PLAYREADY" || drmType == "CLEARKEY") {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ )
+ ) {
+ Text(
+ text = "$drmType support coming soon",
+ modifier = Modifier.padding(16.dp),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSecondaryContainer
+ )
+ }
+ } else {
+ // License URL (Widevine only for now)
+ OutlinedTextField(
+ value = licenseUrl,
+ onValueChange = onLicenseUrlChange,
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("License Server URL") },
+ placeholder = { Text("https://license.server.com/license") },
+ singleLine = true,
+ shape = RoundedCornerShape(8.dp)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Custom Headers (JSON)
+ OutlinedTextField(
+ value = drmHeaders,
+ onValueChange = onDrmHeadersChange,
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text("Custom Headers (JSON)") },
+ placeholder = { Text("""{"Authorization": "Bearer token"}""") },
+ singleLine = false,
+ minLines = 2,
+ maxLines = 3,
+ shape = RoundedCornerShape(8.dp)
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Load test stream button
+ if (onLoadTestStream != null) {
+ Button(
+ onClick = onLoadTestStream,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Load Test Stream")
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
@Composable
fun AudioLevelDisplay(
leftLevel: Float,
diff --git a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt
index 72ab80ac..6eac1824 100644
--- a/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt
+++ b/sample/composeApp/src/commonMain/kotlin/sample/app/singleplayer/SinglePlayerScreen.kt
@@ -12,6 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -23,6 +25,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
+import io.github.kdroidfilter.composemediaplayer.DrmConfiguration
+import io.github.kdroidfilter.composemediaplayer.DrmType
import io.github.kdroidfilter.composemediaplayer.InitialPlayerState
import io.github.kdroidfilter.composemediaplayer.PreviewableVideoPlayerState
import io.github.kdroidfilter.composemediaplayer.SubtitleTrack
@@ -61,11 +65,63 @@ private fun SinglePlayerScreen_OnError_Preview() {
@Composable
private fun SinglePlayerScreenCore(playerState: VideoPlayerState) {
MaterialTheme {
- // Default video URL
+ // Default video URL (non-DRM for basic testing)
var videoUrl by remember { mutableStateOf("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4") }
+
+ // DRM test stream URL
+ val drmTestUrl = "https://media.axprod.net/TestVectors/Cmaf/protected_1080p_h264_cbcs/manifest.mpd"
// State for initial player state (PLAY or PAUSE)
var initialPlayerState by remember { mutableStateOf(InitialPlayerState.PLAY) }
+
+ // DRM Settings - AxDRM test stream defaults
+ var drmEnabled by remember { mutableStateOf(false) }
+ var drmType by remember { mutableStateOf("WIDEVINE") }
+ var licenseUrl by remember { mutableStateOf("https://drm-widevine-licensing.axtest.net/AcquireLicense") }
+ var drmHeaders by remember { mutableStateOf("""{"X-AxDRM-Message": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJ2ZXJzaW9uIjogMSwKICAiY29tX2tleV9pZCI6ICI2OWU1NDA4OC1lOWUwLTQ1MzAtOGMxYS0xZWI2ZGNkMGQxNGUiLAogICJtZXNzYWdlIjogewogICAgInR5cGUiOiAiZW50aXRsZW1lbnRfbWVzc2FnZSIsCiAgICAidmVyc2lvbiI6IDIsCiAgICAibGljZW5zZSI6IHsKICAgICAgImFsbG93X3BlcnNpc3RlbmNlIjogdHJ1ZQogICAgfSwKICAgICJjb250ZW50X2tleXNfc291cmNlIjogewogICAgICAiaW5saW5lIjogWwogICAgICAgIHsKICAgICAgICAgICJpZCI6ICIzMDJmODBkZC00MTFlLTQ4ODYtYmNhNS1iYjFmODAxOGEwMjQiLAogICAgICAgICAgImVuY3J5cHRlZF9rZXkiOiAicm9LQWcwdDdKaTFpNDNmd3YremZ0UT09IiwKICAgICAgICAgICJ1c2FnZV9wb2xpY3kiOiAiUG9saWN5IEEiCiAgICAgICAgfQogICAgICBdCiAgICB9LAogICAgImNvbnRlbnRfa2V5X3VzYWdlX3BvbGljaWVzIjogWwogICAgICB7CiAgICAgICAgIm5hbWUiOiAiUG9saWN5IEEiLAogICAgICAgICJwbGF5cmVhZHkiOiB7CiAgICAgICAgICAibWluX2RldmljZV9zZWN1cml0eV9sZXZlbCI6IDE1MCwKICAgICAgICAgICJwbGF5X2VuYWJsZXJzIjogWwogICAgICAgICAgICAiNzg2NjI3RDgtQzJBNi00NEJFLThGODgtMDhBRTI1NUIwMUE3IgogICAgICAgICAgXQogICAgICAgIH0KICAgICAgfQogICAgXQogIH0KfQ._NfhLVY7S6k8TJDWPeMPhUawhympnrk6WAZHOVjER6M"}""") }
+
+ // Helper function to parse headers JSON (simple parser)
+ fun parseHeaders(): Map {
+ return try {
+ if (drmHeaders.isBlank() || drmHeaders == "{}") {
+ emptyMap()
+ } else {
+ // Simple JSON object parser for {"key": "value", ...}
+ val result = mutableMapOf()
+ val content = drmHeaders.trim().removePrefix("{").removeSuffix("}")
+ if (content.isNotBlank()) {
+ val pairs = content.split(",")
+ for (pair in pairs) {
+ val keyValue = pair.split(":")
+ if (keyValue.size == 2) {
+ val key = keyValue[0].trim().removeSurrounding("\"")
+ val value = keyValue[1].trim().removeSurrounding("\"")
+ result[key] = value
+ }
+ }
+ }
+ result
+ }
+ } catch (e: Exception) {
+ emptyMap()
+ }
+ }
+
+ // Helper function to create DRM config
+ fun createDrmConfig(): DrmConfiguration? {
+ if (!drmEnabled || licenseUrl.isBlank()) return null
+ val type = when (drmType) {
+ "WIDEVINE" -> DrmType.WIDEVINE
+ "PLAYREADY" -> DrmType.PLAYREADY
+ "CLEARKEY" -> DrmType.CLEARKEY
+ else -> DrmType.WIDEVINE
+ }
+ return DrmConfiguration(
+ drmType = type,
+ licenseUrl = licenseUrl,
+ licenseHeaders = parseHeaders()
+ )
+ }
// List of subtitle tracks and the currently selected track
val subtitleTracks = remember { mutableStateListOf() }
@@ -177,20 +233,39 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) {
onVideoUrlChange = { videoUrl = it },
onOpenUrl = {
if (videoUrl.isNotEmpty()) {
- playerState.openUri(videoUrl, initialPlayerState)
+ playerState.openUri(videoUrl, createDrmConfig(), initialPlayerState)
}
},
initialPlayerState = initialPlayerState,
onInitialPlayerStateChange = { initialPlayerState = it }
)
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // DRM Controls
+ DrmControlsCard(
+ licenseUrl = licenseUrl,
+ onLicenseUrlChange = { licenseUrl = it },
+ drmHeaders = drmHeaders,
+ onDrmHeadersChange = { drmHeaders = it },
+ drmEnabled = drmEnabled,
+ onDrmEnabledChange = { drmEnabled = it },
+ drmType = drmType,
+ onDrmTypeChange = { drmType = it },
+ onLoadTestStream = {
+ videoUrl = drmTestUrl
+ }
+ )
}
}
} else {
// Portrait layout (vertical)
+ val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
+ .verticalScroll(scrollState)
) {
// Header with title
PlayerHeader(title = "Compose Media Player Sample",)
@@ -199,8 +274,8 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) {
VideoDisplay(
playerState = playerState,
modifier = Modifier
- .weight(1f)
- .fillMaxWidth(),
+ .fillMaxWidth()
+ .height(220.dp),
contentScale = selectedContentScale
)
@@ -229,12 +304,29 @@ private fun SinglePlayerScreenCore(playerState: VideoPlayerState) {
onVideoUrlChange = { videoUrl = it },
onOpenUrl = {
if (videoUrl.isNotEmpty()) {
- playerState.openUri(videoUrl, initialPlayerState)
+ playerState.openUri(videoUrl, createDrmConfig(), initialPlayerState)
}
},
initialPlayerState = initialPlayerState,
onInitialPlayerStateChange = { initialPlayerState = it }
)
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // DRM Controls
+ DrmControlsCard(
+ licenseUrl = licenseUrl,
+ onLicenseUrlChange = { licenseUrl = it },
+ drmHeaders = drmHeaders,
+ onDrmHeadersChange = { drmHeaders = it },
+ drmEnabled = drmEnabled,
+ onDrmEnabledChange = { drmEnabled = it },
+ drmType = drmType,
+ onDrmTypeChange = { drmType = it },
+ onLoadTestStream = {
+ videoUrl = drmTestUrl
+ }
+ )
}
}
diff --git a/sample/composeApp/src/webMain/resources/drm-helper.js b/sample/composeApp/src/webMain/resources/drm-helper.js
new file mode 100644
index 00000000..64dafb1f
--- /dev/null
+++ b/sample/composeApp/src/webMain/resources/drm-helper.js
@@ -0,0 +1,374 @@
+/**
+ * DRM Helper Functions for ComposeMediaPlayer
+ * These functions bridge Kotlin/WASM with EME browser APIs
+ */
+
+// Global namespace for DRM functions
+window.DrmHelper = window.DrmHelper || {};
+
+/**
+ * Check if EME is supported
+ */
+window.DrmHelper.isSupported = function() {
+ return typeof navigator !== 'undefined' &&
+ typeof navigator.requestMediaKeySystemAccess === 'function';
+};
+
+/**
+ * Request MediaKeySystemAccess
+ * @param {string} keySystem - The key system identifier (e.g., "com.widevine.alpha")
+ * @param {string} configJson - JSON string of MediaKeySystemConfiguration array
+ * @returns {Promise}
+ */
+window.DrmHelper.requestAccess = function(keySystem, configJson) {
+ if (!this.isSupported()) {
+ return Promise.resolve(null);
+ }
+
+ try {
+ var config = JSON.parse(configJson);
+ return navigator.requestMediaKeySystemAccess(keySystem, config);
+ } catch(e) {
+ console.error('DrmHelper.requestAccess failed:', e);
+ return Promise.reject(e);
+ }
+};
+
+/**
+ * Create MediaKeys from MediaKeySystemAccess
+ * @param {MediaKeySystemAccess} access
+ * @returns {Promise}
+ */
+window.DrmHelper.createMediaKeys = function(access) {
+ return access.createMediaKeys();
+};
+
+/**
+ * Set MediaKeys on video element
+ * @param {HTMLVideoElement} video
+ * @param {MediaKeys} mediaKeys
+ * @returns {Promise}
+ */
+window.DrmHelper.setMediaKeys = function(video, mediaKeys) {
+ return video.setMediaKeys(mediaKeys);
+};
+
+/**
+ * Create a media key session
+ * @param {MediaKeys} mediaKeys
+ * @param {string} sessionType - "temporary" or "persistent-license"
+ * @returns {MediaKeySession}
+ */
+window.DrmHelper.createSession = function(mediaKeys, sessionType) {
+ return mediaKeys.createSession(sessionType || 'temporary');
+};
+
+/**
+ * Generate a license request
+ * @param {MediaKeySession} session
+ * @param {string} initDataType
+ * @param {ArrayBuffer} initData
+ * @returns {Promise}
+ */
+window.DrmHelper.generateRequest = function(session, initDataType, initData) {
+ return session.generateRequest(initDataType, initData);
+};
+
+/**
+ * Update session with license
+ * @param {MediaKeySession} session
+ * @param {ArrayBuffer} license
+ * @returns {Promise}
+ */
+window.DrmHelper.updateSession = function(session, license) {
+ return session.update(license);
+};
+
+/**
+ * Close session
+ * @param {MediaKeySession} session
+ * @returns {Promise}
+ */
+window.DrmHelper.closeSession = function(session) {
+ if (session && typeof session.close === 'function') {
+ return session.close();
+ }
+ return Promise.resolve();
+};
+
+/**
+ * Fetch license from server
+ * @param {string} licenseUrl
+ * @param {ArrayBuffer} message - The license request message
+ * @param {Object} headers - Additional headers for the request
+ * @returns {Promise}
+ */
+window.DrmHelper.fetchLicense = function(licenseUrl, message, headers) {
+ var fetchOptions = {
+ method: 'POST',
+ body: message,
+ headers: Object.assign({ 'Content-Type': 'application/octet-stream' }, headers || {})
+ };
+
+ return fetch(licenseUrl, fetchOptions)
+ .then(function(response) {
+ if (!response.ok) {
+ throw new Error('License request failed: ' + response.status + ' ' + response.statusText);
+ }
+ return response.arrayBuffer();
+ });
+};
+
+/**
+ * Setup DRM for a video element with full workflow
+ * @param {HTMLVideoElement} video
+ * @param {string} keySystem - e.g., "com.widevine.alpha"
+ * @param {string} licenseUrl
+ * @param {Object} licenseHeaders
+ * @returns {Promise<{mediaKeys: MediaKeys, cleanup: Function}>}
+ */
+window.DrmHelper.setup = function(video, keySystem, licenseUrl, licenseHeaders) {
+ var self = this;
+ var mediaKeys = null;
+ var sessions = [];
+
+ var config = [{
+ initDataTypes: ['cenc', 'keyids', 'webm'],
+ videoCapabilities: [
+ { contentType: 'video/mp4; codecs="avc1.42E01E"' },
+ { contentType: 'video/mp4; codecs="avc1.4D401E"' },
+ { contentType: 'video/mp4; codecs="avc1.64001E"' },
+ { contentType: 'video/webm; codecs="vp8"' },
+ { contentType: 'video/webm; codecs="vp9"' }
+ ],
+ audioCapabilities: [
+ { contentType: 'audio/mp4; codecs="mp4a.40.2"' },
+ { contentType: 'audio/webm; codecs="opus"' },
+ { contentType: 'audio/webm; codecs="vorbis"' }
+ ],
+ distinctiveIdentifier: 'optional',
+ persistentState: 'optional',
+ sessionTypes: ['temporary']
+ }];
+
+ function handleEncrypted(event) {
+ console.log('[DRM] Encrypted event received:', event.initDataType);
+
+ if (!mediaKeys) {
+ console.error('[DRM] MediaKeys not available');
+ return;
+ }
+
+ var session = mediaKeys.createSession('temporary');
+ sessions.push(session);
+
+ session.addEventListener('message', function(messageEvent) {
+ console.log('[DRM] Session message:', messageEvent.messageType);
+
+ self.fetchLicense(licenseUrl, messageEvent.message, licenseHeaders)
+ .then(function(license) {
+ console.log('[DRM] License received, size:', license.byteLength);
+ return session.update(license);
+ })
+ .then(function() {
+ console.log('[DRM] License applied successfully');
+ })
+ .catch(function(error) {
+ console.error('[DRM] License acquisition failed:', error);
+ });
+ });
+
+ session.addEventListener('keystatuseschange', function() {
+ console.log('[DRM] Key status changed');
+ session.keyStatuses.forEach(function(status, keyId) {
+ console.log('[DRM] Key status:', status);
+ });
+ });
+
+ session.generateRequest(event.initDataType, event.initData)
+ .then(function() {
+ console.log('[DRM] License request generated');
+ })
+ .catch(function(error) {
+ console.error('[DRM] generateRequest failed:', error);
+ });
+ }
+
+ return navigator.requestMediaKeySystemAccess(keySystem, config)
+ .then(function(access) {
+ console.log('[DRM] Got MediaKeySystemAccess for:', keySystem);
+ return access.createMediaKeys();
+ })
+ .then(function(keys) {
+ console.log('[DRM] MediaKeys created');
+ mediaKeys = keys;
+ return video.setMediaKeys(keys);
+ })
+ .then(function() {
+ console.log('[DRM] MediaKeys attached to video');
+ video.addEventListener('encrypted', handleEncrypted);
+
+ return {
+ mediaKeys: mediaKeys,
+ cleanup: function() {
+ video.removeEventListener('encrypted', handleEncrypted);
+ sessions.forEach(function(s) {
+ s.close().catch(function(e) {
+ console.log('[DRM] Session close error:', e);
+ });
+ });
+ sessions = [];
+ }
+ };
+ });
+};
+
+/**
+ * Get key system string for DRM type
+ * @param {string} drmType - "WIDEVINE", "PLAYREADY", or "CLEARKEY"
+ * @returns {string}
+ */
+window.DrmHelper.getKeySystem = function(drmType) {
+ switch((drmType || '').toUpperCase()) {
+ case 'WIDEVINE': return 'com.widevine.alpha';
+ case 'PLAYREADY': return 'com.microsoft.playready';
+ case 'CLEARKEY': return 'org.w3.clearkey';
+ default: return 'com.widevine.alpha';
+ }
+};
+
+/**
+ * Setup DASH playback with DRM using dash.js
+ * Matches the reference implementation from dashif.org
+ * @param {HTMLVideoElement} video
+ * @param {string} url - The DASH manifest URL (.mpd)
+ * @param {string} drmType - "WIDEVINE", "PLAYREADY", or "CLEARKEY"
+ * @param {string} licenseUrl
+ * @param {Object} licenseHeaders
+ * @returns {{player: Object, cleanup: Function}}
+ */
+window.DrmHelper.setupDash = function(video, url, drmType, licenseUrl, licenseHeaders) {
+ // Check if dash.js is available
+ if (typeof dashjs === 'undefined') {
+ console.error('[DRM] dash.js not loaded! Include it before drm-helper.js');
+ return null;
+ }
+
+ // Convert JsString to native string if needed
+ var urlStr = (typeof url === 'object' && url.toString) ? url.toString() : String(url);
+ var licenseUrlStr = (typeof licenseUrl === 'object' && licenseUrl.toString) ? licenseUrl.toString() : String(licenseUrl);
+ var drmTypeStr = (typeof drmType === 'object' && drmType.toString) ? drmType.toString() : String(drmType);
+
+ console.log('[DRM] setupDash called:', {
+ url: urlStr,
+ drmType: drmTypeStr,
+ licenseUrl: licenseUrlStr,
+ hasHeaders: licenseHeaders ? Object.keys(licenseHeaders).length : 0
+ });
+
+ // Build protection data exactly like the reference implementation
+ var keySystem = this.getKeySystem(drmTypeStr);
+ var protData = {};
+
+ protData[keySystem] = {
+ "serverURL": licenseUrlStr,
+ "priority": 0
+ };
+
+ // Add headers if provided
+ if (licenseHeaders && typeof licenseHeaders === 'object') {
+ var headerKeys = Object.keys(licenseHeaders);
+ if (headerKeys.length > 0) {
+ protData[keySystem].httpRequestHeaders = {};
+ for (var i = 0; i < headerKeys.length; i++) {
+ var hKey = headerKeys[i];
+ var hVal = licenseHeaders[hKey];
+ // Convert to native string
+ protData[keySystem].httpRequestHeaders[hKey] =
+ (typeof hVal === 'object' && hVal.toString) ? hVal.toString() : String(hVal);
+ }
+ }
+ }
+
+ console.log('[DRM] Protection data:', JSON.stringify(protData, null, 2));
+
+ // Create player
+ var player = dashjs.MediaPlayer().create();
+
+ // Initialize and set protection (same order as reference)
+ player.initialize(video, urlStr, true);
+ player.setProtectionData(protData);
+
+ // Log events for debugging
+ player.on(dashjs.MediaPlayer.events.ERROR, function(e) {
+ console.error('[DRM] dash.js error:', e);
+ });
+
+ player.on(dashjs.MediaPlayer.events.PROTECTION_CREATED, function(e) {
+ console.log('[DRM] Protection created');
+ });
+
+ player.on(dashjs.MediaPlayer.events.KEY_SYSTEM_SELECTED, function(e) {
+ console.log('[DRM] Key system selected:', e.data ? e.data.keySystem.systemString : 'unknown');
+ });
+
+ player.on(dashjs.MediaPlayer.events.LICENSE_REQUEST_COMPLETE, function(e) {
+ if (e.error) {
+ console.error('[DRM] License request failed:', e.error);
+ } else {
+ console.log('[DRM] License request complete');
+ }
+ });
+
+ player.on(dashjs.MediaPlayer.events.KEY_SESSION_CREATED, function(e) {
+ console.log('[DRM] Key session created');
+ });
+
+ player.on(dashjs.MediaPlayer.events.KEY_STATUSES_CHANGED, function(e) {
+ console.log('[DRM] Key statuses changed');
+ });
+
+ player.on(dashjs.MediaPlayer.events.PLAYBACK_STARTED, function(e) {
+ console.log('[DRM] Playback started!');
+ });
+
+ player.on(dashjs.MediaPlayer.events.CAN_PLAY, function(e) {
+ console.log('[DRM] Can play');
+ });
+
+ return {
+ player: player,
+ cleanup: function() {
+ console.log('[DRM] Cleaning up dash.js player');
+ try {
+ player.reset();
+ } catch(e) {
+ console.log('[DRM] Player reset error:', e);
+ }
+ }
+ };
+};
+
+/**
+ * Check if URL is a DASH manifest
+ * @param {string} url
+ * @returns {boolean}
+ */
+window.DrmHelper.isDashUrl = function(url) {
+ return url && (url.toLowerCase().endsWith('.mpd') || url.includes('.mpd?'));
+};
+
+/**
+ * Parse JSON string to object (helper for WASM which can't call JSON.parse directly)
+ */
+window.DrmHelper.parseJson = function(jsonString) {
+ try {
+ var str = (typeof jsonString === 'object' && jsonString.toString) ? jsonString.toString() : String(jsonString);
+ return JSON.parse(str);
+ } catch(e) {
+ console.error('[DRM] JSON parse error:', e);
+ return null;
+ }
+};
+
+console.log('[DRM] DrmHelper loaded (with dash.js support)');
diff --git a/sample/composeApp/src/webMain/resources/index.html b/sample/composeApp/src/webMain/resources/index.html
index 1425da5f..0be739cb 100644
--- a/sample/composeApp/src/webMain/resources/index.html
+++ b/sample/composeApp/src/webMain/resources/index.html
@@ -4,6 +4,10 @@
Compose Media Player
+
+
+
+