From 49ddb89f74aedac886ae4e344b9691601dea798b Mon Sep 17 00:00:00 2001 From: Tom Van Laerhoven Date: Fri, 9 Oct 2026 09:32:51 +0200 Subject: [PATCH] Bridge liveOffset player configuration on Android The liveOffset property in PlayerConfiguration was only bridged at the source level on Android. Since the native SDK no longer exposes it on THEOplayerConfig.Builder, apply it as the default live offset for sources that do not specify their own. Also clean up PlayerConfigAdapter: shared nullable accessors for ReadableMap, single lookup of nested ads/ima configuration, and fix sessionID reading the ppid value instead. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 + .../com/theoplayer/PlayerConfigAdapter.kt | 147 +++++++----------- .../com/theoplayer/ReactTHEOplayerView.kt | 2 +- .../com/theoplayer/source/SourceAdapter.kt | 13 +- 4 files changed, 73 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce426501e..680f47da5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed - Fixed an issue where re-registering a content protection integration for the same key system kept the previously registered factory active, causing stale DRM integrations to be used for subsequent sources. +- Fixed `liveOffset` in `PlayerConfiguration` not being applied on Android; it is now used as the default live offset for sources that do not specify their own `liveOffset`. +- Fixed `sessionID` in the `ads.ima` player configuration being ignored on Android. ## [11.7.0] - 18-09-26 diff --git a/android/src/main/java/com/theoplayer/PlayerConfigAdapter.kt b/android/src/main/java/com/theoplayer/PlayerConfigAdapter.kt index 5e9029123..f77acdac5 100644 --- a/android/src/main/java/com/theoplayer/PlayerConfigAdapter.kt +++ b/android/src/main/java/com/theoplayer/PlayerConfigAdapter.kt @@ -18,6 +18,7 @@ import com.theoplayer.media.MediaSessionConfigAdapter private const val PROP_LICENSE = "license" private const val PROP_LICENSE_URL = "licenseUrl" +private const val PROP_LIVE_OFFSET = "liveOffset" private const val PROP_PRELOAD = "preload" private const val PROP_UI_ENABLED = "uiEnabled" private const val PROP_CAST_STRATEGY = "strategy" @@ -59,18 +60,18 @@ private const val PROP_CMCD_ENDPOINT_URL = "url" class PlayerConfigAdapter(private val configProps: ReadableMap?) { /** Returns whether the player facade is enabled at creation. Missing or null values default to false. */ - fun usePlayerFacade(): Boolean = configProps?.let { - it.hasKey(PROP_USE_PLAYER_FACADE) && !it.isNull(PROP_USE_PLAYER_FACADE) && it.getBoolean(PROP_USE_PLAYER_FACADE) - } ?: false + fun usePlayerFacade(): Boolean = configProps?.getBooleanOrNull(PROP_USE_PLAYER_FACADE) ?: false /** * Whether debug logs from the native SDK should be enabled. */ - fun debugLogsEnabled(): Boolean { - return configProps?.let { - if (it.hasKey(PROP_DEBUG_LOGS_ENABLED)) it.getBoolean(PROP_DEBUG_LOGS_ENABLED) else false - } ?: false - } + fun debugLogsEnabled(): Boolean = configProps?.getBooleanOrNull(PROP_DEBUG_LOGS_ENABLED) ?: false + + /** + * The offset in seconds used to determine the live point, or null when not configured. + * The value is applied as the default live offset for sources that do not specify their own. + */ + fun liveOffset(): Double? = configProps?.getDoubleOrNull(PROP_LIVE_OFFSET) /** * Get general THEOplayerConfig object; these properties apply: @@ -80,11 +81,14 @@ class PlayerConfigAdapter(private val configProps: ReadableMap?) { */ fun playerConfig(): THEOplayerConfig { return THEOplayerConfig.Builder().apply { + pip(PipConfiguration.Builder().build()) + // Opt-out for auto-integrations for now + autoIntegrations(false) configProps?.run { - getString(PROP_LICENSE)?.let { license -> + getStringOrNull(PROP_LICENSE)?.let { license -> license(license) } - getString(PROP_LICENSE_URL)?.let { licenseUrl -> + getStringOrNull(PROP_LICENSE_URL)?.let { licenseUrl -> licenseUrl(licenseUrl) } if (hasKey(PROP_RETRY_CONFIG) || hasKey(PROP_USE_HTTPENGINE)) { @@ -93,18 +97,9 @@ class PlayerConfigAdapter(private val configProps: ReadableMap?) { if (hasKey(PROP_THEOLIVE_CONFIG)) { theoLive(theoLiveConfig()) } - pip(PipConfiguration.Builder().build()) - // Opt-out for auto-integrations for now - autoIntegrations(false) - if (hasKey(PROP_HLS_DATERANGE)) { - hlsDateRange(getBoolean(PROP_HLS_DATERANGE)) - } - if (hasKey(PROP_MULTIMEDIA_TUNNELING_ENABLED)) { - tunnelingEnabled(getBoolean(PROP_MULTIMEDIA_TUNNELING_ENABLED)) - } - if (hasKey(PROP_SYSTEM_CAPTION_STYLE)) { - useSystemCaptionStyle(getBoolean(PROP_SYSTEM_CAPTION_STYLE)) - } + getBooleanOrNull(PROP_HLS_DATERANGE)?.let { hlsDateRange(it) } + getBooleanOrNull(PROP_MULTIMEDIA_TUNNELING_ENABLED)?.let { tunnelingEnabled(it) } + getBooleanOrNull(PROP_SYSTEM_CAPTION_STYLE)?.let { useSystemCaptionStyle(it) } if (hasKey(PROP_CMCD)) { cmcd(cmcdConfig()) } @@ -122,21 +117,11 @@ class PlayerConfigAdapter(private val configProps: ReadableMap?) { private fun networkConfig(): NetworkConfiguration { return NetworkConfiguration.Builder().apply { configProps?.getMap(PROP_RETRY_CONFIG)?.run { - if (hasKey(PROP_RETRY_MAX_RETRIES)) { - maxRetries(getInt(PROP_RETRY_MAX_RETRIES)) - } - if (hasKey(PROP_RETRY_MIN_BACKOFF)) { - minimumBackOff(getDouble(PROP_RETRY_MIN_BACKOFF).toLong()) - } - if (hasKey(PROP_RETRY_MAX_BACKOFF)) { - maximumBackOff(getDouble(PROP_RETRY_MAX_BACKOFF).toLong()) - } - } - configProps?.run { - if (hasKey(PROP_USE_HTTPENGINE)) { - useHttpEngine(getBoolean(PROP_USE_HTTPENGINE)) - } + getIntOrNull(PROP_RETRY_MAX_RETRIES)?.let { maxRetries(it) } + getDoubleOrNull(PROP_RETRY_MIN_BACKOFF)?.let { minimumBackOff(it.toLong()) } + getDoubleOrNull(PROP_RETRY_MAX_BACKOFF)?.let { maximumBackOff(it.toLong()) } } + configProps?.getBooleanOrNull(PROP_USE_HTTPENGINE)?.let { useHttpEngine(it) } }.build() } @@ -147,40 +132,26 @@ class PlayerConfigAdapter(private val configProps: ReadableMap?) { */ fun imaSdkSettings(): ImaSdkSettings { return ImaSdkFactory.getInstance().createImaSdkSettings().apply { + // The partner provided player type. + setPlayerType("THEOplayer") + // The partner provided player version. + setPlayerVersion(THEOplayerGlobal.getVersion()) configProps?.getMap(PROP_ADS_CONFIGURATION)?.getMap(PROP_IMA_CONFIGURATION)?.run { // Specifies whether VMAP and ad rules ad breaks are automatically played. - if (hasKey(PROP_AUTOPLAY_AD_BREAKS)) { - autoPlayAdBreaks = getBoolean(PROP_AUTOPLAY_AD_BREAKS) - } + getBooleanOrNull(PROP_AUTOPLAY_AD_BREAKS)?.let { autoPlayAdBreaks = it } // Feature flags and their states. Used to control experimental features. - if (hasKey(PROP_FEATURE_FLAGS)) { - val convertedMap: MutableMap = mutableMapOf() - getMap(PROP_FEATURE_FLAGS)?.toHashMap()?.forEach { (key, value) -> - convertedMap[key] = value as String - } - setFeatureFlags(convertedMap) + getMap(PROP_FEATURE_FLAGS)?.toHashMap()?.let { flags -> + setFeatureFlags(flags.mapValues { (_, value) -> value as String }) } // The maximum number of VAST redirects. - if (hasKey(PROP_MAX_REDIRECTS)) { - maxRedirects = getInt(PROP_MAX_REDIRECTS) - } - // The partner provided player type. - setPlayerType("THEOplayer") - // The partner provided player version. - setPlayerVersion(THEOplayerGlobal.getVersion()) + getIntOrNull(PROP_MAX_REDIRECTS)?.let { maxRedirects = it } // The Publisher Provided Identification (PPID) sent with ads request. - if (hasKey(PROP_PPID)) { - setPpid(getString(PROP_PPID) ?: "") - } + getStringOrNull(PROP_PPID)?.let { setPpid(it) } // The session ID to identify a single user session. This should be a UUID. It // is used exclusively for frequency capping across the user session. - if (hasKey(PROP_SESSION_ID)) { - setSessionId(getString(PROP_PPID) ?: "") - } + getStringOrNull(PROP_SESSION_ID)?.let { setSessionId(it) } // Toggles debug mode which will output detailed log information to the console. - if (hasKey(PROP_ENABLE_DEBUG_MODE)) { - isDebugMode = getBoolean(PROP_ENABLE_DEBUG_MODE) - } + getBooleanOrNull(PROP_ENABLE_DEBUG_MODE)?.let { isDebugMode = it } } } } @@ -191,38 +162,30 @@ class PlayerConfigAdapter(private val configProps: ReadableMap?) { * @see IMA SDK for Android. */ fun adsRenderSettings(): AdsRenderingSettings { + val adsProps = configProps?.getMap(PROP_ADS_CONFIGURATION) return ImaSdkFactory.getInstance().createAdsRenderingSettings().apply { - configProps?.getMap(PROP_ADS_CONFIGURATION)?.run { - if (hasKey(PROP_UI_ENABLED) && !getBoolean(PROP_UI_ENABLED)) { + adsProps?.run { + if (getBooleanOrNull(PROP_UI_ENABLED) == false) { setUiElements(emptySet()) disableUi = true } - if (hasKey(PROP_PRELOAD)) { - val preloadTypeString = getString(PROP_PRELOAD) - enablePreloading = preloadTypeString !== "none" + getStringOrNull(PROP_PRELOAD)?.let { preloadType -> + enablePreloading = preloadType != "none" } - if (hasKey(PROP_ALLOWED_MIMETYPES)) { - setMimeTypes(ArrayList().apply { - getArray(PROP_ALLOWED_MIMETYPES)?.toArrayList()?.forEach { - add(it as String) - } - }) + getArray(PROP_ALLOWED_MIMETYPES)?.toArrayList()?.let { mimeTypes -> + setMimeTypes(ArrayList(mimeTypes.filterIsInstance())) } } // bitrate and timeout are configured under the ima config - configProps?.getMap(PROP_ADS_CONFIGURATION)?.getMap(PROP_IMA_CONFIGURATION)?.run { - if (hasKey(PROP_BITRATE)) { - bitrateKbps = getInt(PROP_BITRATE) - } + adsProps?.getMap(PROP_IMA_CONFIGURATION)?.run { + getIntOrNull(PROP_BITRATE)?.let { bitrateKbps = it } // The time needs to be in milliseconds on android but seconds on ios. // we unify the prop from javascript by multiplying it by 1000 here - if (hasKey(PROP_IMA_AD_LOAD_TIMEOUT)) { - setLoadVideoTimeout(getInt(PROP_IMA_AD_LOAD_TIMEOUT) * 1000) - } + getIntOrNull(PROP_IMA_AD_LOAD_TIMEOUT)?.let { setLoadVideoTimeout(it * 1000) } - if (hasKey(PROP_IMA_FOCUS_SKIP_BUTTON_WHEN_AVAILABLE)) { - focusSkipButtonWhenAvailable = getBoolean(PROP_IMA_FOCUS_SKIP_BUTTON_WHEN_AVAILABLE) + getBooleanOrNull(PROP_IMA_FOCUS_SKIP_BUTTON_WHEN_AVAILABLE)?.let { + focusSkipButtonWhenAvailable = it } } } @@ -277,14 +240,24 @@ class PlayerConfigAdapter(private val configProps: ReadableMap?) { ) } - private fun theoLiveConfig (): THEOLiveConfig { + private fun theoLiveConfig(): THEOLiveConfig { val config = configProps?.getMap(PROP_THEOLIVE_CONFIG) return THEOLiveConfig.Builder( - externalSessionId = config?.getString(PROP_THEOLIVE_EXTERNAL_SESSION_ID), - analyticsDisabled = if (config?.hasKey(PROP_THEOLIVE_ANALYTICS_DISABLED) == true) - config.getBoolean(PROP_THEOLIVE_ANALYTICS_DISABLED) - else false, - discoveryUrl = config?.getString(PROP_THEOLIVE_DISCOVERY_URL) + externalSessionId = config?.getStringOrNull(PROP_THEOLIVE_EXTERNAL_SESSION_ID), + analyticsDisabled = config?.getBooleanOrNull(PROP_THEOLIVE_ANALYTICS_DISABLED) ?: false, + discoveryUrl = config?.getStringOrNull(PROP_THEOLIVE_DISCOVERY_URL) ).build() } + + private fun ReadableMap.getBooleanOrNull(key: String): Boolean? = + if (hasKey(key) && !isNull(key)) getBoolean(key) else null + + private fun ReadableMap.getIntOrNull(key: String): Int? = + if (hasKey(key) && !isNull(key)) getInt(key) else null + + private fun ReadableMap.getDoubleOrNull(key: String): Double? = + if (hasKey(key) && !isNull(key)) getDouble(key) else null + + private fun ReadableMap.getStringOrNull(key: String): String? = + if (hasKey(key) && !isNull(key)) getString(key) else null } diff --git a/android/src/main/java/com/theoplayer/ReactTHEOplayerView.kt b/android/src/main/java/com/theoplayer/ReactTHEOplayerView.kt index f7af5dfa7..ff765e157 100644 --- a/android/src/main/java/com/theoplayer/ReactTHEOplayerView.kt +++ b/android/src/main/java/com/theoplayer/ReactTHEOplayerView.kt @@ -188,7 +188,7 @@ class ReactTHEOplayerView(private val reactContext: ThemedReactContext) : fun setSource(source: ReadableMap?) { try { - val sourceDescription = SourceAdapter().parseSourceFromJS(source) + val sourceDescription = SourceAdapter(config?.liveOffset()).parseSourceFromJS(source) adBridge.setSource(sourceDescription) player?.source = sourceDescription } catch (exception: THEOplayerException) { diff --git a/android/src/main/java/com/theoplayer/source/SourceAdapter.kt b/android/src/main/java/com/theoplayer/source/SourceAdapter.kt index 303c02564..422f4863b 100644 --- a/android/src/main/java/com/theoplayer/source/SourceAdapter.kt +++ b/android/src/main/java/com/theoplayer/source/SourceAdapter.kt @@ -102,7 +102,10 @@ private const val CMCD_USER_ID = "userId" private const val CMCD_EVENT_ENDPOINTS = "eventEndpoints" private const val CMCD_ENDPOINT_URL = "url" -class SourceAdapter { +/** + * @param defaultLiveOffset The player-level live offset in seconds, used for sources that do not specify their own `liveOffset`. + */ +class SourceAdapter(private val defaultLiveOffset: Double? = null) { private val gson = Gson() companion object { @@ -257,9 +260,13 @@ class SourceAdapter { jsonTypedSource.optJSONObject(PROP_LATENCY_CONFIGURATION)?.let { tsBuilder.latencyConfiguration(parseLatencyConfiguration(it)) } - if (jsonTypedSource.has(PROP_LIVE_OFFSET)) { - tsBuilder.liveOffset(jsonTypedSource.getDouble(PROP_LIVE_OFFSET)) + // A source-level liveOffset takes precedence over the player-level default. + val liveOffset = if (jsonTypedSource.has(PROP_LIVE_OFFSET) && !jsonTypedSource.isNull(PROP_LIVE_OFFSET)) { + jsonTypedSource.getDouble(PROP_LIVE_OFFSET) + } else { + defaultLiveOffset } + liveOffset?.let { tsBuilder.liveOffset(it) } if (jsonTypedSource.has(PROP_HLS_DATERANGE)) { tsBuilder.hlsDateRange(jsonTypedSource.getBoolean(PROP_HLS_DATERANGE)) }