Android, Business, Product, Tech

May 26, 2020

TikTok clone for beginners

SHARE
facebook
linkedin
twitter

Why use ready-made products when you can create modern applications yourself? This time, we propose to create your own TikTok, which is a pipeline for downloading and transcoding video to HLS format and broadcasting it on Android Devices using Amazon services.

Introduction

This article is an update and addition to this article. The purpose of this article is an Android application that uploads a video to Amazon S3 and plays transcoded video (similar to popular video apps like TikTok, Musical.ly, Snapchat). This guide will allow users quickly and easily to deploy applications using stream video with an adaptive bit rate.

The pipeline works as follows:
1) User uploads the video to the S3 Bucket,
2) Lambda function responds to the .mp4 file
3) Node.JS script. calls the Elastic Transcoder Pipeline, according to the configuration, transcodes the input video, and records the video on the S3 Bucket.
4) User checks whether there are video files in HLS format on the S3 Bucket and launches them, depending on the bandwidth, the player selects the desired bitrate.


Amazon service configuration

  • At the first stage, we need to create an Amazon Mobile Hub project and connect the necessary services:

Select Android and click Add.

Select Hosting & Streaming and follow the steps as in the screenshots.

  • In the second step, we will create an Amazon Elastic Transcoder pipeline to transcode video into HTTP Live Streaming (HLS) segments. Here we indicate the location information of the final HLS files. This step is well described in the initial publication under step 2, paragraph A.
  • The next step is to create Lambda function. For this, you need to return to the Amazon console and enter Lambda in the search line. Then follow these steps.

Select Create function.

Enter a name for your function and select Create function.

In the next step, we will give permission to the Lambda function to use the Elastic Transcoder Pipeline. To do this, select Permissions > Manage these permissions. Without this, the function cannot transcode downloaded videos.

In the window that opens, select permissions AWSLambdaBasicExecutionRole-…

In a new window select Edit Policy.

Next, go to the JSON tab and add the following permission to the Statement array and select Review Policy:

{ "Effect": "Allow",
"Action": [
"ElasticTranscoder:CreateJob"],
"Resource": "*"}

These manipulations make it possible to use the Elastic Transcoder Pipeline service, a Lambda function.
When we have given the necessary permissions for the lambda function, we can rewrite the function code that is generated by default. To do this, in the Configuration tab, you will find the Function code section, which contains the lambda code below.

exports.handler = async (event) => {
    // TODO implement
    const response = {
        statusCode: 200,
        body: JSON.stringify('Hello from Lambda!'),};
    return response;
};

You need to replace this lambda function with the following code, in the constant PIPELINE_ID you should replace it with the ID of your pipeline created in step 2 and select save:


const aws = require('aws-sdk');
const elastictranscoder = new aws.ElasticTranscoder();
const s3 = new aws.S3(2006-03-01);
// Constants
const PIPELINE_ID = '1579171263014-oj2d4o';
const SEGMENT_DURATION = '2'; // seconds
const OUTPUT_FOLDER    = 'content/';
const CONTENT_FOLDERS_REGEXP = /content\/(.+)\/.+/;
const CONTENT_INDEX    = 'index.json';
const PLAYLIST_NAME    = 'default';
exports.handler = function(event, context) {
   console.log('Received S3 Event:');
   console.log(JSON.stringify(event, 2));
   const bucket = event.Records[0].s3.bucket.name;
   const key = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, ' '));
   const params = {
       Bucket: bucket,
       Key: key
   };
   s3.headObject(params, function(err, data) {
       if (err) {
           console.error(err, err.stack);
           context.fail('Error fetching object metadata: ' + err);
           return;
       }
       console.log('S3 Object HEAD Result:');
       console.log(JSON.stringify(data, 2));
       const metadata = data.Metadata;
       metadata.userIp = event.Records[0].requestParameters.sourceIPAddress;
       metadata.userPrincipal = event.Records[0].userIdentity.principalId;
       const videoId =
           String(new Date().getTime()) +
           '-' +
           event.Records[0].userIdentity.principalId.split(':')[1];
       const outputPrefix = OUTPUT_FOLDER + videoId + '/';
       console.log('Allocated Video ID : ' + videoId);
       var request =
           {"Inputs":[{"Key":key}],
            "OutputKeyPrefix":outputPrefix,
            "Outputs":[
              {"Key":'160k',
               "PresetId":'1351620000001-200060',
               "SegmentDuration":SEGMENT_DURATION},
              {"Key":'400k',
               "PresetId":'1351620000001-200050',
               "SegmentDuration":SEGMENT_DURATION},
              {"Key":'600k',
               "PresetId":'1351620000001-200040',
               "SegmentDuration":SEGMENT_DURATION},
              {"Key":'1000k',
               "PresetId":'1351620000001-200030',
               "SegmentDuration":SEGMENT_DURATION},
              {"Key":'1500k',
               "PresetId":'1351620000001-200020',
               "SegmentDuration":SEGMENT_DURATION},
              {"Key":'2000k',
               "PresetId":'1351620000001-200010',
               "SegmentDuration":SEGMENT_DURATION}
            ],
            "Playlists":[{
                "Format":"HLSv3",
                "Name":PLAYLIST_NAME,
                "OutputKeys":[
                  '160k',
                  '400k',
                  '600k',
                  '1000k',
                  '1500k',
                  '2000k'
                ]
            }],
            "PipelineId":PIPELINE_ID,
            "UserMetadata":metadata};
       console.log('Elastic Transcoder Request:');
       elastictranscoder.createJob(request, function (err, data) {
           if (err) {
               console.error(err, err.stack);
               context.fail(err);
               return;
           }
           appendJobToContentIndex(bucket, videoId, (err, data) => {
             if (err) {
               console.error(err, err.stack);
               context.fail(err);
               return;
             } else {
               console.log('OK');
               context.succeed(data);
             }
           });
       });
   });
};
function appendJobToContentIndex(bucket, videoId, callback) {
 const fileList = [];
 listFilesFromS3(bucket, null, fileList, (err, data) => {
     if (err) {
         const message = 'Unable to list files in bucket ' + bucket + '. ' + err;
         console.error(message);
         callback(message);
         return;
     }
     const fileMap = {};
     fileList
       .filter(filename => {
         return filename.match(/content\/.+\//);
       })
       .map(filename => filename.replace(CONTENT_FOLDERS_REGEXP, '$1'))
       .forEach(filename => {
         fileMap[filename] = 1;
       });
     // Add latest video to the list
     fileMap[videoId] = 1;
     const newIndex = JSON.stringify(Object.keys(fileMap).sort().reverse());
     var params = {
       Body: newIndex,
       Bucket: bucket,
       Key: OUTPUT_FOLDER + CONTENT_INDEX,
     };
     s3.putObject(params, callback);
 });
}
// List files in S3 bucket.
function listFilesFromS3(bucketName, marker, files, callback) {
 var params = {
   Bucket: bucketName
 };
 if (marker) {
   params.Marker = marker;
 }
 s3.listObjects(params, function(err, data) {
   if (err) {
     return callback(err);
   }
   data.Contents.forEach(file => {
       files.push(file.Key);
   });
   if (data.IsTruncated) {
     var length = data.Contents.length;
     var marker = data.Contents[length-1].Key;
     listFilesFromS3(bucketName, marker, files, callback);
   } else {
     return callback(undefined, files);
   }
 });
}

The function above not only converts .mp4 video to HLS but also overwrites the index.json file in this file where information about the available videos for playback is stored. The first time the video is uploaded, the index.json file will be created, next uploads will only overwrite this file. This is done so Android applications can discover how many videos are there to play. A video entry will be added only if it is successfully transcoded and uploaded to Bucket S3.

We also need our lambda function to intercept .mp4 videos that come to our S3 hosting. To do this, add a trigger to the function, in the Configuration > Designer tabs, select Add trigger:

Fill out the form as shown below and select your Bucket Hosting:

Now we have a fully configured lambda function.

The final step in configuring the backend part. Will be using CloudFront configuration, which is well described and identical to the original article, Step 2 point. E Grant CloudFront read permissions by adding a bucket policy just for CloudFront.
The final step will be granting permission to write files on the Bucket S3 to other users. To do this, we enter the console, select the service S3 > nameofyourproject-hosting-mobilehub-… > Permissions > Access Control List > Public access and select List objects, Write objects.

After all the points above are completed, we have a fully configured backend, in order to check the operation of the entire backend, you can download the mp4 movie to your Bucket S3 using the console. If everything is done correctly the Bucket should create a folder called content (it can take a while so please refresh the page couple time):

Build Mobile App

After the backend is fully configured, we can start developing the Android application. To do this, first we need to download the configuration file from the Mobile Hub project. Once you navigate to your project you should be able to see Android in the Apps section. Select Integrated > Download Cloud Config:

The downloaded configuration file will be called awsconfiguration.json. Place the downloaded file in your Android project, in the folder: DemoApp / app / src / main / res / raw. In the Link of this article you will find an example application with minimal functionality, in which you need to put your configuration file in the appropriate folder and you can start the application.

In order to play a video stream, you can use the player available in the Android SDK ExoPlayer v2, this player supports video playback in HLS format. In the proposed demo application, the author uses ExoMedia, this library is based on the base ExoPlayer but has a simplified interface for configuration and has an adaptive choice of video quality depending on Internet bandwidth.

As mentioned earlier, we will use the ExoMedia library for this in your activity_main.xml file add the following widget:

<com.devbrackets.android.exomedia.ui.widget.VideoView
android:id="@+id/videoView"
android:gravity="center"
app:useDefaultControls="true"
app:useTextureViewBacking="true"
app:videoScale="fitCenter"/>

In the onCreate function, you must initialize your AWS client.

AWSMobileClient.getInstance().initialize(this).execute()
val identityManager = IdentityManager.getDefaultIdentityManager()
identityManager.getUserID(this)

To get a list of files you can use the following function:

private fun getContentID() {
      val CONTENT_INDEX_KEY = "content/index.json"
      var cloudFrontURL = ""
       try {
           val contentManagerConfig = AWSMobileClient.getInstance()
               .configuration
               .optJsonObject("ContentManager")
           val bucket = contentManagerConfig.getString("Bucket")
           val region = contentManagerConfig.getString("Region")
           cloudFrontURL = contentManagerConfig.getString("CloudFrontURL")

           val outputDir = cacheDir
           val outputFile = File.createTempFile("index", ".json", outputDir)

           val s3 = AmazonS3Client(AWSMobileClient.getInstance().credentialsProvider)
           s3.setRegion(Region.getRegion(region))
           val transferUtility = TransferUtility(s3, applicationContext)

           val observer = transferUtility.download(
               bucket,
               CONTENT_INDEX_KEY,
               outputFile)
           observer.setTransferListener(object : TransferListener {
               override fun onStateChanged(id: Int, state: TransferState) {
                   Log.d(TAG, "S3 Download State change : $state")

                   if (TransferState.COMPLETED == state) {
                       try {
                           val contentsIndex = IOUtils.toString(FileInputStream(outputFile))
                           val jsonArray = JSONTokener(contentsIndex).nextValue() as JSONArray
                           if (jsonArray.length() <= 0) {
                               this.onError(id, IllegalStateException("No videos available."))
                               return
                           }
                         //  setupVideoView(cloudFrontURL, jsonArray.get(0)) 
                       } catch (e: IOException) {
                           this.onError(id, e)
                       } catch (e: JSONException) {
                           this.onError(id, e)
                       }
                   }
               }
               override fun onProgressChanged(id: Int, bytesCurrent: Long, bytesTotal: Long) {
                   Log.d(TAG, "S3 Download progress : $bytesCurrent")
               }
               override fun onError(id: Int, ex: Exception) {
                   if (ex.message!!.contains("key does not exist")) {
                       return
                   }
               }
           })
       } catch (e: JSONException) {
           Log.e(TAG, e.message, e)
       } catch (e: IOException) {
           Log.e(TAG, e.message, e)
       }
   }

In the backend part, the lambda function, upon successfully transcoding the video and uploading it to the Bucket, adds the id of the video to the index.json file. The function above downloads the index.json file if one is available. Available videos are recorded in jsonArray constant. We also parse information about cloudFrontURL from the configuration file. This address is required to receive a video stream.
With information on cloudFrontURL and the video ID, we can begin to configure the player. To do this, call the following function:

private fun setupVideoView(cloudFrontURL: String, contentId: String) {
   val videoView =findViewById(R.id.videoView) as VideoView
   videoView.reset()
   videoView.setOnPreparedListener(object :  OnPreparedListener{
       override fun onPrepared() {
           videoView.start()
       }
   })
   videoView.setScaleType(ScaleType.CENTER_CROP)
   videoView.setOnCompletionListener(object : OnCompletionListener{
       override fun onCompletion() {
           // TODO implement logic after playing video
       }
   })
   val uri = Uri.parse(hostingURL + "/content/" + contentId + "/default.m3u8")
   videoView.setVideoURI(uri)
   videoView.requestFocus()
}

This function, after preparing the video, will immediately begin playing it. As you can see we were able fairly quickly to implement a basic application for displaying video from a server using an adaptive bitrate depending on the bandwidth of the Internet.
You should also remember adding the necessary permissions to your AndroidManifest.xml and adding the necessary libraries to the build.gradle file.

Conclusion

Summing up, this guide will help you in developing your own video hosting. Using HLS video playback, we get a significant advantage over users since they can play videos even with poor Internet quality, and this in some cases saves time. It is also worth noting that you can use this pipeline not only to transcode video from .mp4 to HLS, but also all the available functionality of the Elastic Transcoder Pipeline service, or you can intercept any files that come and apply your script to them and take certain actions which are a very convenient mechanism. Thanks and happy coding!

Links

https://aws.amazon.com/blogs/mobile/streaming-videos-to-mobile-app-users-via-amazon-cloudfront-cdn/#
https://github.com/aws-samples/aws-mobile-simple-video-transcoding https://github.com/google/ExoPlayer
https://github.com/brianwernick/ExoMedia https://bitbucket.org/chcodes/hlsandroid/src/master/

avatar

Andrei Liudkievich

AI Dev

Interested in collaborating?

Get an Estimate

+48 536 008 632

sales@codahead.com

Rydlówka 20, 30-363 Kraków

What would you like to do?

Build something new

Improve existing project

Extend my team

What is the scope of your project?

Software Development

AI

IoT

Product Design

Others

*Required fields.