Ques/Help/Req Как публиковать и воспроизводить видео на Android с помощью опенсорс-библиотеки и стриминговой платформы EdgeЦентр

XakeR

Member
Регистрация
13.05.2006
Сообщения
1 912
Реакции
0
Баллы
16
Местоположение
Ukraine
Как публиковать и воспроизводить видео на Android с помощью опенсорс-библиотеки и стриминговой платформы EdgeЦентр0


Сделать собственный сервис, где пользователи могли бы смотреть готовые видео на смартфонах в хорошем качестве, с адаптивным битрейтом кажется довольно сложной и дорогой задачей. Но на самом деле реализовать публикацию и проигрывание 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.
 
198 114Темы
635 085Сообщения
3 618 401Пользователи
EeOneНовый пользователь
Верх