From 8e869ae104d715373520e86902938dd7d9afeb25 Mon Sep 17 00:00:00 2001 From: Micoder-dev Date: Fri, 6 Feb 2026 10:38:07 +0530 Subject: [PATCH] Add Widevine DRM support for web platforms Features: - DrmConfiguration data class for DRM settings - DrmHelper JavaScript module for EME integration - dash.js integration for DASH/MPD playback - DRM controls in sample app with test stream - HTTPS dev server config (required for EME) Supported: Widevine on Chrome/Firefox/Edge Coming soon: PlayReady, ClearKey See WEB_DRM_SUPPORT.md for usage documentation --- WEB_DRM_SUPPORT.md | 143 +++++++ .../composemediaplayer/DrmConfiguration.kt | 74 ++++ .../composemediaplayer/VideoPlayerState.kt | 21 + .../VideoPlayerState.web.kt | 24 ++ .../VideoPlayerSurfaceImpl.kt | 177 ++++++++- .../jsinterop/EncryptedMediaExtensions.kt | 164 ++++++++ .../src/webMain/resources/drm-helper.js | 374 ++++++++++++++++++ .../app/singleplayer/PlayerComponents.kt | 124 ++++++ .../app/singleplayer/SinglePlayerScreen.kt | 102 ++++- .../src/webMain/resources/drm-helper.js | 374 ++++++++++++++++++ .../src/webMain/resources/index.html | 4 + .../webpack.config.d/https.config.js | 4 + 12 files changed, 1574 insertions(+), 11 deletions(-) create mode 100644 WEB_DRM_SUPPORT.md create mode 100644 mediaplayer/src/commonMain/kotlin/io/github/kdroidfilter/composemediaplayer/DrmConfiguration.kt create mode 100644 mediaplayer/src/webMain/kotlin/io/github/kdroidfilter/composemediaplayer/jsinterop/EncryptedMediaExtensions.kt create mode 100644 mediaplayer/src/webMain/resources/drm-helper.js create mode 100644 sample/composeApp/src/webMain/resources/drm-helper.js create mode 100644 sample/composeApp/webpack.config.d/https.config.js 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 + + + +
diff --git a/sample/composeApp/webpack.config.d/https.config.js b/sample/composeApp/webpack.config.d/https.config.js new file mode 100644 index 00000000..3d1f4e60 --- /dev/null +++ b/sample/composeApp/webpack.config.d/https.config.js @@ -0,0 +1,4 @@ +// Enable HTTPS for EME/DRM testing +config.devServer = config.devServer || {}; +config.devServer.server = 'https'; +config.devServer.host = '0.0.0.0';