Stream Videos from Cloudflare in Android WebView

Displaying videos in apps is now common place. There are many different routes to explore, each with their pros and conns. It also depends on your requirements as to which avenue you go down. For the purposes of this article, we are just talking about displaying a video, hosted on Cloudflare, for which we have the URI given to us.

Warning

When testing I could not get it to work on anything but a real device. When using Genymotion, a white area was displayed where the video was meant to be. On an Android emulator it would load the player but not allow the video to play. I think from the logs, it had an issue with loading the embedded Javascript but I couldn’t work out what the issue was.

I/chromium: [INFO:CONSOLE(0)] "Not allowed to load local resource: blob:null/f47f10fc-4f4a-47dd-bf03-9991cdf2366f", source: about:blank (0)
I/chromium: [INFO:CONSOLE(1)] "VIDEOJS:", source: https://embed.cloudflarestream.com/embed/r4xu.fla9.latest.js (1)
Android emulator video not playing

Cloudflare Player API

Cloudlfare expose to us this useful API that allows us to embed our content’s ID into the HTML and load it into our webview. Here is the code provided by them.

<stream src="5d5bc37ffcf54c9b82e996823bffbb81" controls preload height="240px" width="480px"></stream>
<script data-cfasync="false" defer type="text/javascript" src="https://embed.cloudflarestream.com/embed/r4xu.fla9.latest.js?video=5d5bc37ffcf54c9b82e996823bffbb81"></script>

Now obviously some work needs to go into getting the video ID into the HTML but also configuring the height and width of the video.

Building the HTML

Extracting the Video Id

First we need to extract the content ID somehow. In my case, it was passed to me via the API with the id being the last part in the uri.

val videoId = Uri.parse(video.uri).lastPathSegment!!

Sizing the Video

Fortunately, the API returns the original height and width of the video. This allows us to get the correct aspect ratio for viewing it without the stream placing the video within black margins.

if (initialWidth == null) initialWidth = width
val initialWidth = initialWidth!!

val params = layoutParams
val ratio = initialWidth.toDouble()/video.width.px.toDouble()
params.height = (ratio * video.height.px.toDouble()).toInt()
params.width = initialWidth
layoutParams = params

So here we are calculating the width and height of the container based on the size of the original video. Nb. The initialWidth property is a constant variable on the companion object of this class. It’s only set once and serves to keep the original maximum width of the view.

Notice the .px function as well. This is a method that converts it into density-DEpendent pixels; how many pixels it takes up on this specific device.

val Int.px: Int
    get() = (this * Resources.getSystem().displayMetrics.density).toInt()

Putting it All Together

We have our video id and we have our width and height at the correct aspect ratio. Now we need to display the video. Here is how I construct the HTML string we shall be displaying.

val streamString = "<style type=\"text/css\"> html, body {width:100%;height: 100%;margin: 0px;padding: 0px;}\n" +
                "</style><stream src=\"$videoId\" controls preload height=\"${params.height.dp}px\" width=\"${params.width.dp-1}px\"></stream>\n" +
                "<script data-cfasync=\"false\" defer type=\"text/javascript\" src=\"https://embed.cloudflarestream.com/embed/r4xu.fla9.latest.js?video=$videoId\"></script></body>"

Notice the first line. This stops a margin being added to the page which causes it to allow scrolling. This way, there is no gap around the video.

CloudflareVideoView Class

For reusability it makes sense to put all of this into a dedicated view that extends WebView. See below for the full source of this class.

class CloudflareVideoView  @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : WebView(context,attrs, defStyleAttr) {

    companion object {
        private var initialWidth : Int? = null
    }

    init {
        settings.javaScriptEnabled = true
    }

    fun loadVideo(video: VideoDataModel) {
        if (height == 0 && width == 0) afterMeasured { configureWithVideo(video) }
        else configureWithVideo(video)
    }

    private fun configureWithVideo(video: VideoDataModel) {
        if (initialWidth == null) initialWidth = width
        val initialWidth = initialWidth!!

        val params = layoutParams
        val ratio = initialWidth.toDouble()/video.width.px.toDouble()
        params.height = (ratio * video.height.px.toDouble()).toInt()
        params.width = initialWidth
        layoutParams = params

        val videoId = Uri.parse(video.uri).lastPathSegment!!
        val streamString = "<style type=\"text/css\"> html, body {width:100%;height: 100%;margin: 0px;padding: 0px;}\n" +
                "</style><stream src=\"$videoId\" controls preload height=\"${params.height.dp}px\" width=\"${params.width.dp-1}px\"></stream>\n" +
                "<script data-cfasync=\"false\" defer type=\"text/javascript\" src=\"https://embed.cloudflarestream.com/embed/r4xu.fla9.latest.js?video=$videoId\"></script></body>"
        loadDataWithBaseURL("",streamString,"text/html","UTF-8",null)
    }

}

Leave a comment

Your email address will not be published. Required fields are marked *