Сделать собственный сервис, где пользователи могли бы смотреть готовые видео на смартфонах в хорошем качестве, с адаптивным битрейтом кажется довольно сложной и дорогой задачей. Но на самом деле реализовать публикацию и проигрывание VOD (Video on Demand, видео по запросу) — не так уж и сложно, а в качестве составных частей можно использовать опенсорс.

Меня зовут Денис Филиппов, я руководитель отдела разработки стриминговой платформы EdgeЦентр. Сегодня расскажу вам, как с помощью нашей платформы и опенсорс-библиотеки tus-android-client сделать приложение, где пользователи смогут смотреть видео на Android.

Материал будет полезен всем, кто хочет реализовать качественное воспроизведение видео на Android-смартфонах с минимальными денежными расходами.

Подключаемся к стриминговой платформе EdgeЦентр

Чтобы хранить видео и управлять им, понадобится видеохостинг. Всё, о чём я буду рассказывать в статье, я делал именно с помощью нашей стриминговой платформы. Но при желании то же самое можно сделать и с помощью любого аналогичного сервиса.

У нашей платформы есть тестовый период 14 дней. Загрузить в этот период можно не больше 10 минут VOD. Но этого будет  достаточно, чтобы понять, как сервис работает, всё ли вас устраивает, и не потратить при этом ни копейки лишнего.

Чтобы начать пользоваться сервисом, нужно создать аккаунт. А после этого создать в личном кабинете токен для работы с API.

Публикуем VOD на платформе

Для публикации видео нам понадобится опенсорс-библиотека tus-android-client. Она позволяет загружать VOD по протоколу TUS (открытый протокол для возобновляемой загрузки файлов). Как работать с библиотекой, расскажу чуть позже.

На платформу EdgeЦентр мы можем заливать уже готовые видео или контент, снятый с камеры на смартфоне (сразу после записи). С первым сценарием всё очень просто. А на втором мы остановимся подробнее.

1. Захват видео с камеры

Для работы с камерой в Android есть 3 варианта: Camera API, Camera 2 API и CameraX API. Самый простой и актуальный — третий, поэтому будем использовать его.

Чтобы использовать CameraX API, нужно добавить некоторые зависимости в файл build.gradle (на уровне приложения).

def camerax_version = "1.2.0" 
implementation("androidx.camera:camera-core:${camerax_version}")
implementation("androidx.camera:camera-camera2:${camerax_version}")
implementation("androidx.camera:camera-lifecycle:${camerax_version}")
implementation("androidx.camera:camera-video:${camerax_version}")
implementation("androidx.camera:camera-view:${camerax_version}")

Дальше запрашиваем разрешения, которые нужны нам для работы с камерой. Их мы указываем в файле AndroidManifest.xml.

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission
 android:name="android.permission.WRITE_EXTERNAL_STORAGE"
 android:maxSdkVersion="28"/>

Также заодно указываем разрешение для доступа в интернет — оно нам понадобится позже, когда мы будем отправлять видео.

Так как доступ к камере и микрофону являются опасными разрешениями, их нужно запросить явно у пользователя:

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
  super.onViewCreated(view, savedInstanceState)
  if (allPermissionGranted()) {
      startCamera()
  } else {
     requestPermissions.launch(REQUIRED_PERMISSIONS)
  }
}

private fun allPermissionGranted() = REQUIRED_PERMISSIONS.all {
  ContextCompat.checkSelfPermission(
     requireContext().applicationContext, it
  ) == PackageManager.PERMISSION_GRANTED
}

private val requestPermissions = registerForActivityResult(
  ActivityResultContracts.RequestMultiplePermissions()
) {
  if (allPermissionGranted()) {
      startCamera()
  } else {
     Toast.makeText(
       requireContext(), 
       "Permissions not granted by the user.", 
       Toast.LENGTH_SHORT
     ).show()
   }
}

companion object {
  private const val TAG = "UploadVideoFragment"
  private val REQUIRED_PERMISSIONS = mutableListOf(
     Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO
  ).apply {
     if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
         add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
     }
  }.toTypedArray()
}

Получаем превью с камеры. Для взаимодействия с камерой смартфона CameraX использует абстракции — варианты использования (Use cases):

  • Preview — отображение захвата с камеры на экране смартфона.

  • Image analyses — анализ и обработка изображений.

  • Image capture — захват фото с камеры и его сохранение на устройстве.

  • Video capture — захват видео и аудио с камеры и микрофона.

Их можно комбинировать и использовать одновременно. В нашем случае мы будем одновременно использовать Preview и Video capture.

Добавляем PreviewView из библиотеки CameraX к Layout Activity или Fragment-а:

<androidx.camera.view.PreviewView
 android:id="@+id/cameraPreview"
 android:layout_width="match_parent"
 android:layout_height="match_parent"
 android:keepScreenOn="true"/>

Добавляем метод startCamera()

private var cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
private var videoCapture: VideoCapture<Recorder>? = null

private fun startCamera() {
  val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext())
 
  cameraProviderFuture.addListener({
    val cameraProvider = cameraProviderFuture.get()
    val preview = Preview.Builder().build().also {
        it.setSurfaceProvider(binding.cameraPreview.surfaceProvider)
    }
 
    val recorder = Recorder.Builder()
        .setQualitySelector(QualitySelector.from(Quality.HIGHEST))
        .build()
    videoCapture = VideoCapture.withOutput(recorder)
 
   try {
     cameraProvider.unbindAll()
     cameraProvider.bindToLifecycle(
       viewLifecycleOwner,
       cameraSelector,
       preview,
       videoCapture
      )
   } catch (e: java.lang.Exception) {
     e.printStackTrace()
   }
  }, ContextCompat.getMainExecutor(requireContext()))
}

Здесь мы привязываем нашу камеру к жизненному циклу процесса приложения и передаем ей нужные варианты использования (preview и videoCapture).

Дальше нам нужно захватить видео с камеры. Для старта захвата добавляем метод  startRecording()

private var recording: Recording? = null

private fun startRecoding() {
  val videoCapture = videoCapture ?: return
  binding.videoCaptureBtn.isEnabled = false
 
  val name =
      SimpleDateFormat("yyyy-MM-dd-HH-mm-ss",
Locale.US).forma(System.currentTimeMillis())
  val contentValues = ContentValues().apply {
      put(MediaStore.MediaColumns.DISPLAY_NAME, name)
      put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4")
      if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
          put(MediaStore.Video.Media.RELATIVE_PATH, "MoviesCameraX-Video")
      }
  }
  val mediaStoreOutputOptions = MediaStoreOutputOptions.Builder(
      requireActivity().contentResolver,
      MediaStore.Video.Media.EXTERNAL_CONTENT_URI
  ).setContentValues(contentValues).build()
 
  recording = videoCapture.output.prepareRecording(requireContext(),mediaStoreOutputOptions).apply {
    val audioPermission = PermissionChecker.checkSelfPermission(
        requireContext(), Manifest.permission.RECORD_AUDIO
    )
    if (audioPermission == PermissionCheckerPERMISSION_GRANTED) {
        withAudioEnabled()
    }
  }.start(mainThreadExecutor, videoRecordEventListener)
}

Здесь мы определяем имя видеофайла и то, куда будет записано видео. Также в метод старт мы передаём videoRecordEventListener: Consumer<VideoRecordEvent>, в котором мы будем реагировать на события, полученные от камеры в процессе записи видео.

Дальше делаем реакции на события записи:

private var outputVideoUri: Uri? = null

private val videoRecordEventListener = Consumer { event: VideoRecordEvent ->
  when (event) {
    is VideoRecordEvent.Status -> {
       val timeInNanos = event.recordingStats.recordedDurationNanos
       val time = timeInNanos.nanoseconds.toComponents { hours,minutes, seconds, _ ->
           TIME_FORMAT.format(hours, minutes, seconds)
       }
       binding.recordedDuration.text = time
    }
    is VideoRecordEvent.Start -> {
       setStartRecordingUIState()
    }
    is VideoRecordEvent.Pause -> {
       binding.playPauseRecord.setImageResource(R.drawableic_play_24)
    }
    is VideoRecordEvent.Resume -> {
       binding.playPauseRecord.setImageResource(R.drawableic_pause_24)
    }
    is VideoRecordEvent.Finalize -> {
       if (!event.hasError()) {
           outputVideoUri = event.outputResults.outputUri
           Toast.makeText(
             requireContext(),
             R.string.video_capture_succeeded,
             Toast.LENGTH_SHORT
           ).show()
       } else {
           recording?.close()
           recording = null
           Log.e(TAG, "${event.error}")
       }
       setStopRecordingUIState()
    }
  }
}

При возникновении события VideoRecordEvent.Finalize, когда мы уже закончили запись, если она прошла без ошибок, нам будет доступно outputUri записанного видео. Это нам как раз понадобится для публикации видео.

2. Загрузка VOD

Первым делом отправляем запрос на публикацию видео на платформе EdgeЦентр. Прежде чем начать непосредственно отгрузку видео, нам нужно получить его ID, который будет на сервере. Для этого отправляем POST запрос на сервер:

interface VideoApi {
  //...
  @POST("./streaming/videos")
  fun postVideo(
      @Header("Authorization") accessToken: String,
      @Body body: PostVideoRequestBody
  ): Single<VideoItemResponse>
  //...
}

fun uploadVideo(localVideoUri: Uri) {
    val requestBody = getPostVideoRequestBody(localVideoUri)
 
    if (requestBody != null) {
        val accessToken = getAccessToken(app)
 
        compositeDisposable.add(
        (app as EdgeApp)
             .videoApi
             .postVideo("Bearer $accessToken", requestBody)
             .subscribeOn(Schedulers.computation())
             .observeOn(AndroidSchedulers.mainThread())
             .subscribe({
               getUrlAndTokenToUploadVideo(
                 it.id,
                 requestBody.videoName,
                 localVideoUri.toString()
               )
             }, {
               _uploadingVideoState.value = UploadingState.Failure(requestBody.videoName)
             })
        )
    } else {
        _uploadingVideoState.value = UploadingState.Failure
    }
}

В ответе на этот запрос будет находиться ID видео, который мы хотим отправить на сервер.

Для взаимодействия с сервером по REST API я использовал Retrofit (непосредственно для запросов) и RxJava (для асинхронности). Но вы можете использовать и другие инструменты.

Дальше нужно получить URL и token, чтобы можно было отгрузить видео на сервер. И здесь нам понадобится videoId, который мы получили на предыдущем шаге.

interface VideoApi {
  //...
  @GET("/streaming/videos/{video_id}/upload")
  fun getURLandTokenToUploadVideo(
      @Header("Authorization") accessToken: String,
      @Path("video_id") videoId: Int
  ): Single<UploadVideoResponse>
}

private fun getUrlAndTokenToUploadVideo(videoId: Int, videoName: String, videoUri: String) {
  val accessToken = getAccessToken(app)
 
  compositeDisposable.add(
    (app as EdgeApp)
         .videoApi
         .getURLandTokenToUploadVideo("Bearer $accessToken", videoId)
         .subscribeOn(Schedulers.computation())
         .observeOn(AndroidSchedulers.mainThread())
         .subscribe({
           startUploadVideoWorker(it, videoUri)
         }, {
           _uploadingVideoState.value = UploadingState.Failure(videoName)
         })
  )
}

Отгружаем видео на сервер. Этот процесс может занимать довольно много времени, особенно при нестабильном интернете. Поэтому мы вынесем эту операцию в фоновый поток. А для этого надо быть уверенными, что процесс не будет убит системой. А это может произойти по разным причинам. Как раз для таких долгих операций Google рекомендует использовать WorkManager.

Для его использования нам нужно сначала добавить соответствующую зависимость в файл build.gradle (на уровне приложения):

implementation "androidx.work:work-runtime-ktx:2.7.1"

Дальше надо отнаследоваться от класса Worker и переопределить метод doWork(), в котором мы будем выполнять долгую работу.

class UploadVideoWorker(
  private val context: Context,
  workerParams: WorkerParameters
) : Worker(context, workerParams) {
 
  override fun doWork(): Result {
    val notificationId = NOTIFICATION_ID++
 
    return try {
      //...
 
      uploadVideo(tusClient, videoUpload) { uploadedBytes: Long ->
        setForegroundAsync(
          createForegroundInfo(
            notificationId,
            videoUpload.size.toInt(),
            uploadedBytes.toInt()
          )
        )
        //...
      }
      Result.success()
 
    } catch (throwable: Throwable) {
      throwable.printStackTrace()
      Result.failure()
    }
  }
 
  //..
  private fun createForegroundInfo(
      notificationId: Int,
      maxProgress: Int,
      currentProgress: Int
  ) = ForegroundInfo(
      notificationId,
      getNotification(maxProgress, currentProgress)
  )
 
  private fun getNotification(maxProgress: Int, currentProgress: Int): Notification {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        createNotificationChannel()
    }
 
    val cancelIntent = WorkManager.getInstance(context)
        .createCancelPendingIntent(id)
 
    val contentTitle = inputData.getString(VIDEO_NAME)
    val contentText = context.getString(R.string.upload_video)
 
    return NotificationCompat.Builder(context, UPLOAD_VIDEO_CHANNEL_ID)
        .setSmallIcon(R.drawable.ic_app)
        .setContentTitle(contentTitle)
        .setContentText(contentText)
        .setPriority(NotificationCompat.PRIORITY_DEFAULT)
        .setCategory(NotificationCompat.CATEGORY_PROGRESS)
        .addAction(android.R.drawable.ic_delete, "Cancel", cancelIntent)
        .setOngoing(true)
        .setSilent(true)
        .setProgress(maxProgress, currentProgress, false)
        .build()
  }
 
  @RequiresApi(Build.VERSION_CODES.O)
  private fun createNotificationChannel() {
    val notificationChannel = NotificationChannel(
        UPLOAD_VIDEO_CHANNEL_ID,
        UPLOAD_VIDEO_CHANNEL_NAME,
        NotificationManager.IMPORTANCE_DEFAULT
    ).apply {
        description = "channel for upload video"
    }
 
    (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
        .apply {
          createNotificationChannel(notificationChannel)
        }
  }
 
  companion object {
    private var NOTIFICATION_ID = 101
    private const val UPLOAD_VIDEO_CHANNEL_ID = "uploadVideoChannelId"
    private const val UPLOAD_VIDEO_CHANNEL_NAME = "uploadVideoChannelName"
    //...
  }
}

Здесь также стоит обратить внимание на вызов метода setForegroundAsync(). Его нужно использовать, если заданная вами работа может выполняться дольше 10 минут. С помощью этого метода WorkManager сообщает системе, что процесс должен оставаться активным, пока работа выполняется (если это возможно). Без вызова метода нет гарантий, что система не убьет ваш процесс через 10 минут.

Реализовываем метод отгрузки видео при помощи tusClient:

class UploadVideoWorker(
  private val context: Context,
  workerParams: WorkerParameters
) : Worker(context, workerParams) {
 
  override fun doWork(): Result {
    val notificationId = NOTIFICATION_ID++
 
    return try {
      val tusClient = getTusClient()
      val videoUpload = getVideoUpload()
 
      uploadVideo(tusClient, videoUpload) { uploadedBytes: Long ->
        //..
        val uploadedPercent = (uploadedBytes * 100 / videoUpload.size).toInt()
        setProgressAsync(
          workDataOf(
            UPLOADED_PERCENT to uploadedPercent,
          )
        )
      }
      Result.success()
 
    } catch (throwable: Throwable) {
      throwable.printStackTrace()
      Result.failure()
    }
  }
 
  private fun uploadVideo(
    tusClient: TusClient,
    videoUpload: TusAndroidUpload,
    updateProgress: (uploadedBytes: Long) -> Unit
  ) {
    val executor = object : TusExecutor() {
      override fun makeAttempt() {
        try {
          val uploader = tusClient.resumeOrCreateUpload(videoUpload).apply {
            chunkSize = uploadedChunkSizeInBytes
          }
 
          val progressChunkSize = videoUpload.size * stepDisplayedProgressInPercents / 100
          var displayedOffset = progressChunkSize
 
          do {
            if (uploader.offset > displayedOffset) {
              displayedOffset += progressChunkSize
              updateProgress(uploader.offset)
            }
          } while (!isStopped && (uploader.uploadChunk() > -1))
 
          uploader.finish()
        } catch (e: ProtocolException) {
          throw ProtocolException(e.message)
        } catch (e: IOException) {
          throw IOException(e.message)
        }
      }
    }
    executor.makeAttempts()
  }
  private fun getTusClient(): TusClient {
    val uploadUrl = inputData.getString(UPLOAD_VIDEO_URL) ?: ""
    val urlStore = TusPreferencesURLStore(
      context.getSharedPreferences("TUS", 0)
    )
 
    return TusClient().apply {
      uploadCreationURL = URL(uploadUrl)
      enableResuming(urlStore)
    }
  }
 
  private fun getVideoUpload(): TusAndroidUpload {
    val videoName = inputData.getString(VIDEO_NAME) ?: ""
    val clientId = inputData.getInt(CLIENT_ID, 0)
    val videoId = inputData.getInt(VIDEO_ID, 0)
    val videoLocalUri = Uri.parse(
      inputData.getString(VIDEO_LOCAL_URI)
    )
    val token = inputData.getString(VIDEO_TOKEN) ?: ""
 
    return TusAndroidUpload(videoLocalUri, context).apply {
      metadata = mapOf(
        "filename" to videoName,
        "client_id" to clientId.toString(),
        "video_id" to videoId.toString(),
        "token" to token
      )
    }
  }
 
  //...
  companion object {
    //...
    private const val uploadedChunkSizeInBytes = 50 * 1024
    private const val stepDisplayedProgressInPercents = 5
 
    const val UPLOAD_VIDEO_URL = "uploadVideoUrl"
    const val VIDEO_NAME = "name"
    const val CLIENT_ID = "clientId"
    const val VIDEO_TOKEN = "videoToken"
    const val VIDEO_ID = "id"
    const val VIDEO_LOCAL_URI = "videoLocalUri"
    const val UPLOADED_PERCENT = "uploadedPercent"
  }
}

Обратите внимание, что при создании tusClient нам нужно передать в него наш UploadURL, который мы получали ранее. Также при формировании tusAndroidUpload нужно передавать в него метаданные.

Дальше передаём нашу работу WorkManager:

private fun startUploadVideoWorker(
  uploadVideoResponse: UploadVideoResponse,
  videoLocalUri: String
) {
  val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .build()
 
  val videoData = Data.Builder()
    .putString(
      UploadVideoWorker.UPLOAD_VIDEO_URL,
      "https://${uploadVideoResponse.servers[0].hostname}/upload/"
    )
    .putString(UploadVideoWorker.VIDEO_NAME, uploadVideoResponse.uploadVideo.name)
    .putInt(UploadVideoWorker.VIDEO_ID, uploadVideoResponse.uploadVideo.id)
    .putString(UploadVideoWorker.VIDEO_TOKEN, uploadVideoResponse.uploadToken)
    .putString(UploadVideoWorker.VIDEO_LOCAL_URI, videoLocalUri)
    .putInt(UploadVideoWorker.CLIENT_ID, uploadVideoResponse.uploadVideo.clientId)
    .build()
 
  val uploadVideoTask = OneTimeWorkRequest.Builder(UploadVideoWorker::class.java)
    .setConstraints(constraints)
    .setInputData(videoData)
    .build()
 
  workManager.enqueueUniqueWork(
    uploadVideoResponse.uploadVideo.id.toString(),
    ExistingWorkPolicy.REPLACE,
    uploadVideoTask
  )
}

Здесь мы передаём на вход данные, которые нужны для отгрузки видео, и отдаём работу WorkManager.

Дальше нам остаётся наблюдать за процессом отгрузки видео. В WorkManager делать это можно через LiveData. Мы запускали нашу работу как уникальную, а в качестве её уникального имени указывали ID отгружаемого видео. А значит, мы можем получить liveData по указанному ID и подписаться на него, чтобы реагировать на изменения состояния. Пример, как это можно сделать:

val uploadingWorkState = workManager.getWorkInfoByIdLiveData(uploadVideoTask.id)

uploadingWorkState.observeForever(object : Observer<WorkInfo> {
  override fun onChanged(workInfo: WorkInfo) {
    when (workInfo.state) {
      WorkInfo.State.RUNNING -> {
        val progressPercent = workInfo.progress.getInt(
          UploadVideoWorker.UPLOADED_PERCENT,
          0
        )
 
        _uploadingVideoState.value = UploadingState.InProgress(videoName, progressPercent)
      }
      WorkInfo.State.SUCCEEDED -> {
        _uploadingVideoState.value = UploadingState.Success(videoName)
      }
      WorkInfo.State.CANCELLED -> {
        _uploadingVideoState.value = UploadingState.Canceled(videoName)
      }
      WorkInfo.State.FAILED -> {
        _uploadingVideoState.value = UploadingState.Failure(videoName)
      }
      else -> {}
    }
 
    if (workInfo.state.isFinished) {
      _uploadingVideoState.value = null
      uploadingWorkState.removeObserver(this)
    }
  }
})

Обратите внимание, что в состоянии WorkInfo.State.RUNNING мы получаем информацию о прогрессе работы workInfo.progress. Это возможно, потому что в нашем UploadVideoWorker, в методе doWork() мы вызываем метод setProgressAsync() с необходимыми данными.

Воспроизводим VOD на смартфоне

Стриминговая платформа EdgeЦентр отдаёт видеопоток устройствам-клиентам по протоколу HLS, чтобы доставлять контент как можно быстрее и даже в условиях плохого интернета (пусть и качество при этом будет понижаться).

Для воспроизведения hls-потока на Android-смартфоне можно воспользоваться стандартным плеером MediaPlayer или использовать ExoPlayer. Второй для этих целей подойдёт лучше. Но мы рассмотрим оба варианта.

1. Использование MediaPlayer

Здесь нам понадобится компонент VideoView, а также hls-url видео, который мы будем передавать в плеер.

Добавляем VideoView к Layout Activity или Fragment-а:

<VideoView
  android:id="@+id/videoView"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:layout_gravity="center"
  android:keepScreenOn="true"/>

Инициализируем плеер и передаём в него hls-url видео:

private fun initializePlayer() {
  val videoView = binding.videoView
  videoView.setVideoURI(Uri.parse(hlsUrl))
 
  val mediaController = MediaController(videoView.context)
  videoView.setMediaController(mediaController)
  
  videoView.setOnPreparedListener {
    videoView.seekTo(currentFrame)
    binding.progressBar.visibility = View.GONE
    videoView.start()
  }
}

Здесь важно заметить, что стандартный MediaPlayer не может снижать или повышать качество воспроизводимого видео при ухудшении или улучшении интернета. А значит все преимущества HLS-протокола сходят на нет.

2. Использование ExoPlayer

В противовес MediaPlayer, ExoPlayer при получении видеоконтента через hls-протокол может легко переключаться между доступными качествами в зависимости от скорости интернета зрителя. И на сегодняшний день это самое подходящее решение для воспроизведения потоковых видео на Android-устройствах.

Для использования ExoPlayer нам сначала нужно добавить некоторые зависимости в файл build.gradle (на уровне приложения).

implementation 'com.google.android.exoplayer:exoplayer-core:2.18.2'
implementation 'com.google.android.exoplayer:exoplayer-hls:2.18.2'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.18.2'

Добавим PlayerView из библиотеки ExoPlayer к Layout Activity или Fragment-а:

<com.google.android.exoplayer2.ui.PlayerView
  android:id="@+id/exo_player"
  android:layout_width="match_parent"
  android:layout_height="match_parent"/>

Инициализируем плеер и передаём в него hls-url видео:

private fun initializePlayer(){
  val trackSelector = DefaultTrackSelector(this).apply {
    setParameters(buildUponParameters().setMaxVideoSizeSd())
  }
  player = ExoPlayer.Builder(this)
    .setTrackSelector(trackSelector)
    .build()
 
  binding.playerView.player = player
  val mediaItem = MediaItem.fromUri(videoUri)
 
  player?.apply {
    setMediaItem(mediaItem)
    playWhenReady = true
    seekTo(playbackPosition)
    prepare()
  }
}

Готово! Наше видео из стриминговой платформы EdgeЦентр теперь будет воспроизводиться на устройстве. А качество будет меняться в зависимости от пропускной способности сети.

Подведём итоги

Вот так с помощью бесплатных инструментов и сервисов EdgeЦентр можно сделать приложение, где пользователи смогут смотреть видео и делиться ими. Если хотите подробнее изучить проект, исходный код получившегося приложения я выложил на GitHub.

Комментарии (0)