This article details how to integrate Mesh Delivery into your mobile app using ExoPlayer on Android.
Not into tutorials? |
Project Setup
Dependencies
The easiest way to get the Lumen SDK is to add it as a Gradle dependency. We assume you are using Android Studio with the latest tools updates as recommended by Google. If not, drop us a line.
repositories {
maven {
url 'https://sdk.streamroot.io/android'
}
}
Next, add the Streamroot dependency to the build.gradle file of your app module.
implementation 'io.streamroot.lumen.delivery.client:mesh-sdk:22.03.2'
Integrate Lumen SDK
1. Set deliveryClientKey.
Before doing anything, you must set the deliveryClientKey
as a meta-data in the application manifest.
<meta-data
android:name="io.streamroot.lumen.delivery.client.DeliveryClientKey"
android:value="DCKey" />
deliveryClientKey
: Refers to your Lumen unique identifier that you will find in the Account section of the Streamroot Dashboard. If you don't have a deliveryClientKey, you can ask for a free trial on our website.
It is also possible to pass your deliveryClientKey at Lumen Delivery Client instantiation and initialization.
2. SDK Initialization
SDK initialization is done preferably in an application context subclass.
- Create subclass of Application or
MultiDexApplication
if your code base is big and you need Kitkat / minSdkVersion 19 support. - Initialize the SDK
class SRApplication: MultiDexApplication() {
override fun onCreate() {
super.onCreate()
LumenDeliveryClient.initializeApp(this, "DCKey")
}
}
- Point to your custom application subclass in the
AndroidManifest.xml
android:name=".SRApplication"
3. Create a new Lumen Delivery Client instance
Now that you have set the deliveryClientKey, you are able to create the Lumen Delivery Client instances.
private fun initDeliveryClient(newPlayer: SimpleExoPlayer, DefaultLoadControl: LoadControl, bandwidthMeter: ExoPlayerBandwidthMeter) =
LumenDeliveryClient.meshBuilder(applicationContext)
.playerInteractor(ExoPlayerInteractor(newPlayer, loadControl, bandwidthMeter))
.options {
contentId(<string>)
meshProperty(<string>)
deliveryClientKey(<string>)
latency(latency)
logLevel(<string>)
}
.build(mStreamUrl)
Builder parameters:
context
: An instance of Context.-
deliveryClientKey
: A method to pass your deliveryClientKey. It will override the one set in the application manifest. playerInteractor
: An object that implementsplayerInteractor
. See section 7 for more details.contentId
: (optional) A parameter to identify the content and make our Dashboard more user friendly. The default value is the stream URL.meshProperty
: (optional) The property to be used for this content. If none is set, we will use "default". More info here.latency
: A method that works for HLS and MPEG-DASH on live content. If it's set for a VoD content, this parameter will be ignored. The value must be strictly positive and in seconds. We recommend setting the value to 30s.logLevel
: (optional) Set the log level: TRACE | CRITICAL | ERROR | WARNING | INFO | DEBUG | OFF. Defaults to OFF.mStreamUrl
: the URL of your stream. The playback will start from this CDN (if available) in order to reduce the start-up time.
Other DNA options can be called in the builder. Please refer to this article to learn more about them.
It is also required to pass the
loadControl
during the player instantiation.On Live streaming, to improve offload efficiency, we recommend to change the default value of the minimal buffer target to 10s. In order to do so, you need to change the instantiation of the default Exoplayer class
DefaultLoadControl
with this one:
DefaultLoadControl.Builder()
.setBufferDurationsMs(10_000, // overrides default DEFAULT_MIN_BUFFER_MS
DEFAULT_MAX_BUFFER_MS,
DEFAULT_BUFFER_FOR_PLAYBACK_MS,
DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS)
.createDefaultLoadControl();
4. Start the SDK instance
Calling the start()
method on the DC will start the SDK.
deliveryClient.start()
5. Give your player the new URL
Once you have a running instance of the SDK, you must retrieve the final URL and input it to your player instead of your original one.
val finalUrl = lumenDeliveryClient.localUrl()
To maximize compatibility with the SDK we strongly encourage you to allow HTTP <-> HTTPS cross protocol redirects in your ExoPlayer media sources. Check section 8 for more details.
6. Stop the Lumen Delivery Client
Once the video is done playing, you have to stop the SDK you created earlier. Calling the following method will finish the ongoing tasks and release the resources.
deliveryClient.stop()
7. Integrate the Player interactor
This Player interactor module will link ExoPlayer events to our SDK in order to get accurate metrics regarding the client session.
- Looper
looper must return the looper used to create the player. It is mandatory from ExoPlayer 2.9.0 and above.
The recommended way is to use theapplicationLooper
function (ExoPlayer doc) - Loaded TimeRanges
loadedTimeRanges must return a list of TimeRange (in milliseconds).
Each TimeRange represents a continuous interval of content present in the buffer. It is mandatory to return at least the TimeRange going from the playback time to the end of the buffered interval containing the playback time. - Current playback time
playbackTime must return the current time of the player in milliseconds. - Buffer target
bufferTarget must return the target the player will try to buffer.
setBufferTarget allows us to dynamically change the buffer target of the player to optimize DNA performances. - Bandwidth listener
Implementation of Streamroot BandwidthListener and Exoplayer BandwidthMeter.
Allows making an estimation of the bandwidth needed to download the video chunks
Here is an example of the class to add to the app in order to do so.
class PlayerInteractor(
private val player: ExoPlayer,
loadControl: LoadControl,
private val bandwidthMeter: ExoPlayerBandwidthMeter
) : LumenPlayerInteractorBase(),
Player.Listener {
init {
player.addListener(this)
}
override fun onSeekProcessed() {
super.playerStateChange(LumenVideoPlaybackState.SEEKING)
}
override fun onTracksChanged(trackGroups: TrackGroupArray, trackSelections: TrackSelectionArray) {
super.playerTrackSwitch()
}
override fun onPlayerError(error: PlaybackException) {
super.playerError()
}
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
when (playbackState) {
Player.STATE_IDLE -> {
super.playerStateChange(LumenVideoPlaybackState.IDLE)
}
Player.STATE_BUFFERING -> {
super.playerStateChange(LumenVideoPlaybackState.REBUFFERING)
}
Player.STATE_READY -> {
super.playerStateChange(if (playWhenReady) LumenVideoPlaybackState.PLAYING else LumenVideoPlaybackState.PAUSED)
}
Player.STATE_ENDED -> {
super.playerStateChange(LumenVideoPlaybackState.ENDED)
}
}
}
private val handler = Handler(player.applicationLooper)
private fun <T> runSyncOnEPHandler(block: () -> T): T? {
var t: T? = null
val lock = Object()
synchronized(lock) {
handler.post {
synchronized(lock) {
t = block()
lock.notify()
}
}
lock.wait()
}
return t
}
// Conversion from milliseconds to seconds does not use TimeUnit because we want
// to keep a floating precision
private val bridge = BufferTargetBridgeFactory.createInteractor(loadControl)
override fun bufferHealth() = runSyncOnEPHandler { (player.bufferedPosition - player.currentPosition) / 1000.0 }!!
override fun bufferTarget() = runSyncOnEPHandler { bridge.bufferTarget() }!!
override fun setBufferTarget(target: Double) = runSyncOnEPHandler { bridge.setBufferTarget(target) }!!
override fun setEstimatedBandwidth(bps: Long?) {
bps?.let {
bandwidthMeter.setEstimatedBandwidth(bps)
}
}
}
class ExoPlayerBandwidthMeter private constructor(context: Context) : BandwidthMeter {
companion object {
fun new(context: Context) = ExoPlayerBandwidthMeter(context)
}
private val estimatedBandwidth = AtomicLong(DefaultBandwidthMeter.Builder(context).build().bitrateEstimate)
fun setEstimatedBandwidth(bps: Long) {
estimatedBandwidth.set(bps)
}
override fun getBitrateEstimate() = estimatedBandwidth.get()
override fun getTransferListener(): TransferListener? = null
override fun addEventListener(eventHandler: Handler, eventListener: BandwidthMeter.EventListener) { }
override fun removeEventListener(eventListener: BandwidthMeter.EventListener) { }
}
internal interface BufferTargetBridge {
fun bufferTarget(): Double = 0.0
fun setBufferTarget(bufferTarget: Double) {}
}
private abstract class LoadControlBufferTargetBridge(protected val loadControl: LoadControl) :
BufferTargetBridge {
protected fun LoadControl.getAccessibleFieldElseThrow(fieldName: String) = runCatching {
val minBufferField = this::class.java.getDeclaredField(fieldName)
minBufferField.isAccessible = true
minBufferField
}.getOrNull() ?: throw IllegalArgumentException("Impossible to retrieve field `$fieldName` value from LoadControl of type `${this::class.java.simpleName}`")
protected fun LoadControl.getLongFromFieldElseThrow(fieldName: String) = runCatching {
getAccessibleFieldElseThrow(fieldName).getLong(this)
}.getOrNull() ?: throw IllegalArgumentException("Impossible to retrieve long `$fieldName` value from LoadControl of type `${this::class.java.simpleName}`")
companion object {
private const val MAX_BUFFER_FIELD_NAME = "maxBufferUs"
}
protected val maxBufferField = loadControl.getAccessibleFieldElseThrow(MAX_BUFFER_FIELD_NAME)
protected abstract val minBufferUs: Long
override fun bufferTarget(): Double {
return runCatching {
maxBufferField.getLong(loadControl).let { TimeUnit.MICROSECONDS.toSeconds(it) }.toDouble()
}.getOrNull() ?: super.bufferTarget()
}
override fun setBufferTarget(bufferTarget: Double) {
val maxBufferUs = TimeUnit.SECONDS.toMicros(bufferTarget.toLong())
runCatching {
maxBufferField.setLong(
loadControl,
maxOf(minBufferUs, maxBufferUs)
)
}
}
}
// 2.10- & 2.12+
private class LoadControlBufferTargetBridgeV1(loadControl: LoadControl) :
LoadControlBufferTargetBridge(loadControl) {
companion object {
private const val MIN_BUFFER_FIELD_NAME = "minBufferUs"
}
override val minBufferUs = loadControl.getLongFromFieldElseThrow(MIN_BUFFER_FIELD_NAME)
}
// 2.11
private class LoadControlBufferTargetBridgeV2(loadControl: LoadControl, audioOnly: Boolean) :
LoadControlBufferTargetBridge(loadControl) {
companion object {
private const val MIN_BUFFER_AUDIO_FIELD_NAME = "minBufferAudioUs"
private const val MIN_BUFFER_VIDEO_FIELD_NAME = "minBufferVideoUs"
}
override val minBufferUs = loadControl.getLongFromFieldElseThrow(
if (audioOnly) MIN_BUFFER_AUDIO_FIELD_NAME else MIN_BUFFER_VIDEO_FIELD_NAME
)
}
internal object BufferTargetBridgeFactory {
fun createInteractor(loadControl: LoadControl, audioOnly: Boolean = false): BufferTargetBridge {
return runCatching { LoadControlBufferTargetBridgeV1(loadControl) }.getOrNull()?.also { Log.v("Misc", "Using interactor V1") }
?: runCatching { LoadControlBufferTargetBridgeV2(loadControl, audioOnly) }.getOrNull()?.also { Log.v("Misc", "Using interactor V2") }
?: throw java.lang.Exception("Unsupported ExoPlayer version")
}
}
As we use this API at a low frequency, we've decided to uses reflection for this implementation.
It is therefore required to blacklist all ExoPlayer classes when using either Proguard like so
#ExoPlayer
-keep class com.google.android.exoplayer2.** { *; }
-keep interface com.google.android.exoplayer2.** { *; }
8. Network security configuration
Starting with Android 9 (API level 28), cleartext support is disabled by default.
As Streamroot SDK works as a local HTTP proxy, it is required to do one of the following
- Activate back cleartext to reactivate all HTTP calls in the AndroidManifest.xml (Not recommended)
<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
<application
...
android:usesCleartextTraffic="true">
...
</application>
</manifest>
- Add 'localhost' in the allowed domains in a new network_security_config.xml file
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config>
And add this file in the AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest ...>
<application
...
android:networkSecurityConfig="@xml/network_security_config">
...
</application>
</manifest>
More info can be found in the Android documentation.