Удаленный Доступ К Ip-Камерам. Часть 2. Мобильное Приложение



Удаленный доступ к IP-камерам.
</p><p>
 Часть 2. Мобильное приложение

В предыдущем статья Я говорил о чем-то простом сервер для работы с камерами видеонаблюдения, а для быстрого просмотра RTSP-потоков я использовал мобильное приложение VLC, которое меня не совсем устроило по ряду причин.

Ниже под катом вы найдете описание и листинги простого мобильного приложения для Android, написанного специально для камер видеонаблюдения.

Исходники приложения можно скачать с сайта github .

Для тех, кто не хочет создавать APK самостоятельно, вот оно.

связь для готовых файлов.

Возможно, нам всем сейчас немного неинтересны камеры, но Хабр не для политики, верно? УПД: Кажется, из-за последние события в Google Play тема импортозамещения становится еще более актуальной.

На самом деле контент может доставляться пользователю разными способами, например, через веб-приложение.

Но, к сожалению, практически все современные браузеры не поддерживают кодек H.265, который мне очень нужен, поэтому от этого пути пришлось сразу отказаться.

Кроме того, моя схема подключения предполагает два сервера — локальный, с «серым» IP-адресом, и удаленный, с «белым» IP-адресом, который обеспечивает доступ к камерам через Интернет по протоколу TCP. Поэтому одним из основных требований к приложению является возможность явного переключения TCP/UDP. У VLC нет такой роскоши.

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



Лирическое отступление о выборе платформы

Фреймворки для разработки кроссплатформенных приложений тоже пришлось исключить, потому что мне нужно обрабатывать жесты масштабирования и позиционирования изображений, причем делать это максимально плавно.

Кстати, JetBrains предлагает, казалось бы, интересное мультиплатформенное решение — Kotlin Multiplatform Mobile. Надо попробовать! Я устанавливаю плагин KMM в Android Studio и создаю проект по единственному предложенному шаблону.

Мне не нравится структура проекта.

Ладно, может быть можно хотя бы строковые ресурсы переместить в общие? Нет, без бубна не потанцуешь.

Как создать приложение для iOS? Ни в коем случае, для этого вам понадобится iOS. А если учесть, что в стране, где я живу, будущее продукции Apple несколько туманно, смысл вообще теряется.

Было решено: честно буду писать для Android на его официальном языке — Kotlin.

Выполнение

Приложение должно быть максимально простым, я не буду (пока) использовать фрагменты и граф навигации.

Экранов у меня будет всего три: список камер, редактор настроек камеры и экран видео:

Удаленный доступ к IP-камерам.
</p><p>
 Часть 2. Мобильное приложение

Для работы с потоками я буду использовать библиотеку libvlc, сохраняя настройки в приватной директории во внутреннем хранилище устройства в формате json с помощью библиотеки gson. Для взаимодействия с элементами представления мне нравится привязка представления, которая включается параметром viewBinding true в файле build.gradle уровня приложения: build.gradle

  
  
  
  
  
  
  
  
  
  
  
   

plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' } android { compileSdk 32 defaultConfig { applicationId "com.vladpen.cams" minSdk 23 targetSdk 32 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = '1.8' } buildFeatures { viewBinding true } packagingOptions { jniLibs { useLegacyPackaging = true } } } dependencies { implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'com.google.android.material:material:1.5.0' implementation 'com.google.code.gson:gson:2.8.6' implementation 'org.videolan.android:libvlc-all:3.4.9' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' }

В манифесте, помимо трех действий, необходимо не забыть включить разрешение на доступ к сети android.permission.INTERNET: AndroidManifest.xml

<Эxml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android " package="com.vladpen.cams"> <uses-permission android:name="android.permission.INTERNET " /> <application android:allowBackup="true " android:icon="@mipmap/ic_launcher " android:label="@string/app_name " android:roundIcon="@mipmap/ic_launcher_round " android:supportsRtl="true " android:theme="@style/AppTheme "> <activity android:name=".

MainActivity " android:exported="true "> <intent-filter> <action android:name="android.intent.action.MAIN " /> <category android:name="android.intent.category.LAUNCHER " /> </intent-filter> </activity> <activity android:name=".

EditActivity " android:exported="false " /> <activity android:name=".

VideoActivity " android:configChanges="orientation|screenSize|screenLayout|keyboardHidden " android:exported="false " /> </application> </manifest>

Главный экран приложения (MainActivity) содержит список камер recyclerView и ссылки на редактирование/добавление камер: MainActivity.kt

package com.vladpen.cams import android.content.Intent import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.LinearLayoutManager import com.vladpen.StreamData import com.vladpen.StreamsAdapter import com.vladpen.cams.databinding.ActivityMainBinding class MainActivity: AppCompatActivity() { private val binding by lazy { ActivityMainBinding.inflate(layoutInflater) } private val streams by lazy { StreamData.getStreams(this) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) initActivity() } private fun initActivity() { binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.adapter = StreamsAdapter(streams) binding.toolbar.btnBack.visibility = View.GONE binding.toolbar.tvToolbarLabel.text = getString(R.string.app_name) binding.toolbar.tvToolbarLink.text = getString(R.string.add) binding.toolbar.tvToolbarLink.visibility = View.VISIBLE binding.toolbar.tvToolbarLink.setOnClickListener { editScreen() } } private fun editScreen() { val editIntent = Intent(this, EditActivity::class.java) editIntent.putExtra("id", -1) startActivity(editIntent) } }

Activity_main.xml

<Эxml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android " xmlns:app="http://schemas.android.com/apk/res-auto " xmlns:tools="http://schemas.android.com/tools " android:layout_width="match_parent " android:layout_height="match_parent "> <include android:id="@+id/toolbar " layout="@layout/toolbar" /> <androidx.recyclerview.widget.RecyclerView android:id="@+id/recyclerView " android:layout_width="match_parent " android:layout_height="wrap_content " android:textColor="@color/text " app:layout_constraintEnd_toEndOf="parent " app:layout_constraintStart_toStartOf="parent " app:layout_constraintTop_toBottomOf="@+id/toolbar " tools:listitem="@layout/stream_item " /> </androidx.constraintlayout.widget.ConstraintLayout>

Для работы RecyclerView требуется адаптер: StreamsAdapter.kt

package com.vladpen import android.content.Context import android.content.Intent import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.vladpen.cams.VideoActivity import com.vladpen.cams.EditActivity import com.vladpen.cams.databinding.StreamItemBinding class StreamsAdapter(private val dataSet: List<StreamDataModel>) : RecyclerView.Adapter<StreamsAdapter.StreamHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StreamHolder { val binding = StreamItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) return StreamHolder(parent.context, binding) } override fun onBindViewHolder(holder: StreamHolder, position: Int) { val row: StreamDataModel = dataSet[position] holder.bind(position, row) } override fun getItemCount(): Int = dataSet.size inner class StreamHolder(private val context: Context, private val binding: StreamItemBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(position: Int, row: StreamDataModel) { with(binding) { tvStreamName.text = row.name tvStreamName.setOnClickListener { val intent = Intent(context, VideoActivity::class.java) navigate(context, intent, position) } btnEdit.setOnClickListener { val intent = Intent(context, EditActivity::class.java) navigate(context, intent, position) } } } } private fun navigate(context: Context, intent: Intent, position: Int) { intent.setFlags(FLAG_ACTIVITY_NEW_TASK).

putExtra("position", position) context.startActivity(intent) } }

и элемент списка: поток_item.xml

<Эxml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android " xmlns:app="http://schemas.android.com/apk/res-auto " android:layout_width="match_parent " android:layout_height="wrap_content "> <TextView android:id="@+id/tvStreamName " android:layout_width="wrap_content " android:layout_height="wrap_content " android:text= "" android:textSize="20sp " android:padding="16dp " android:textColor="@color/text " android:background="Эattr/selectableItemBackground " app:layout_constraintBottom_toBottomOf="parent " app:layout_constraintStart_toStartOf="parent " app:layout_constraintTop_toTopOf="parent " /> <ImageButton android:id="@+id/btnEdit " android:layout_width="wrap_content " android:layout_height="wrap_content " android:background="@color/background " android:foreground="Эandroid:attr/selectableItemBackground " android:contentDescription="@string/settings " android:padding="10dp " android:src="@drawable/ic_baseline_settings_24 " app:tint="@color/hint " app:layout_constraintBottom_toBottomOf="parent " app:layout_constraintEnd_toEndOf="parent " app:layout_constraintTop_toTopOf="parent " /> </androidx.constraintlayout.widget.ConstraintLayout>

Синглтон StreamData отвечает за хранение данных; формат данных описывается классом данных StreamDataModel: StreamData.kt

package com.vladpen import android.content.Context import android.util.Log import com.google.gson.Gson import com.google.gson.reflect.TypeToken import java.io.File data class StreamDataModel(val name: String, val url: String, val tcp: Boolean) object StreamData { private const val fileName = "streams.json" private var streams = mutableListOf<StreamDataModel>() fun save(context: Context, position: Int, stream: StreamDataModel) { if (position < 0) { streams.add(stream) } else { streams[position] = stream } streams.sortBy { it.name } write(context) } fun delete(context: Context, position: Int) { if (position < 0) { return } streams.removeAt(position) write(context) } private fun write(context: Context) { val json = Gson().

toJson(streams) context.openFileOutput(fileName, Context.MODE_PRIVATE).

use { it.write(json.toByteArray()) } } fun getStreams(context: Context): MutableList<StreamDataModel> { if (streams.size == 0) { try { val filesDir = context.filesDir if (File(filesDir, fileName).

exists()) { val json: String = File(filesDir, fileName).

readText() initStreams(json) } else { Log.i("DATA", "Data file $fileName does not exist") } } catch (e: Exception) { Log.e("Data", e.localizedMessage ?: "Can't read data file $fileName") } } return streams } fun getByPosition(position: Int): StreamDataModel? { if (position < 0 || position >= streams.count()) { return null } return streams[position] } private fun initStreams(json: String) { if (json == "") { return } val listType = object : TypeToken<List<StreamDataModel>>() { }.

type streams = Gson().

fromJson<List<StreamDataModel>>(json, listType).

toMutableList() } }

Камеры (потоки) хранятся в mutableList; Доступ к данным камеры можно получить по индексу (положению).

Экран редактирования настроек камеры (EditActivity) отвечает за добавление, редактирование и удаление записей в списке потоков: EditActivity.kt

package com.vladpen.cams import android.content.Intent import android.os.Bundle import android.view.View import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import com.vladpen.StreamData import com.vladpen.StreamDataModel import com.vladpen.cams.databinding.ActivityEditBinding class EditActivity : AppCompatActivity() { private val binding by lazy { ActivityEditBinding.inflate(layoutInflater) } private val streams by lazy { StreamData.getStreams(this) } private var position: Int = -1 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) initActivity() } private fun initActivity() { position = intent.getIntExtra("position", -1) val stream = StreamData.getByPosition(position) if (stream == null) { position = -1 binding.toolbar.tvToolbarLabel.text = getString(R.string.cam_add) } else { binding.toolbar.tvToolbarLabel.text = stream.name binding.etEditName.setText(stream.name) binding.etEditUrl.setText(stream.url) binding.scEditTcp.isChecked = !stream.tcp binding.tvDeleteLink.visibility = View.VISIBLE binding.tvDeleteLink.setOnClickListener { delete() } } binding.btnSave.setOnClickListener { save() } binding.toolbar.btnBack.setOnClickListener { back() } } private fun save() { if (!validate()) { return } StreamData.save(this, position, StreamDataModel( binding.etEditName.text.toString().

trim(), binding.etEditUrl.text.toString().

trim(), !binding.scEditTcp.isChecked )) back() } private fun validate(): Boolean { val name = binding.etEditName.text.toString().

trim() val url = binding.etEditUrl.text.toString().

trim() var ok = true if (name.isEmpty() || name.length > 255) { binding.etEditName.error = getString(R.string.err_invalid) ok = false } if (url.isEmpty() || url.length > 255) { binding.etEditUrl.error = getString(R.string.err_invalid) ok = false } for (i in streams.indices) { if (i == position) { break } if (streams[i].

name == name) { binding.etEditName.error = getString(R.string.err_cam_exists) ok = false } if (streams[i].

name == url) { binding.etEditUrl.error = getString(R.string.err_cam_exists) ok = false } } return ok } private fun delete() { AlertDialog.Builder(this) .

setMessage(R.string.cam_delete) .

setPositiveButton(R.string.delete) { _, _ -> StreamData.delete(this, position) back() } .

setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() } .

create().

show() } private fun back() { startActivity(Intent(this, MainActivity::class.java)) } }

Activity_edit.xml

<Эxml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android " xmlns:app="http://schemas.android.com/apk/res-auto " android:layout_width="match_parent " android:layout_height="match_parent "> <include android:id="@+id/toolbar " layout="@layout/toolbar"/> <TextView android:id="@+id/tvHintName " android:layout_width="wrap_content " android:layout_height="wrap_content " android:text="@string/cam_name " app:layout_constraintStart_toStartOf="parent " app:layout_constraintEnd_toEndOf="parent " app:layout_constraintTop_toBottomOf="@+id/toolbar " /> <EditText android:id="@+id/etEditName " android:layout_width="match_parent " android:layout_height="wrap_content " android:inputType="text " android:hint="@string/cam_name_hint " android:autofillHints= "" android:gravity="center " app:layout_constraintStart_toStartOf="parent " app:layout_constraintEnd_toEndOf="parent " app:layout_constraintTop_toBottomOf="@+id/tvHintName " /> <TextView android:id="@+id/tvHintUrl " android:layout_width="wrap_content " android:layout_height="wrap_content " android:text="@string/cam_url " android:layout_marginTop="16dp " app:layout_constraintStart_toStartOf="parent " app:layout_constraintEnd_toEndOf="parent " app:layout_constraintTop_toBottomOf="@+id/etEditName " /> <EditText android:id="@+id/etEditUrl " android:layout_width="match_parent " android:layout_height="wrap_content " android:inputType="textUri " android:hint="@string/cam_url_hint " android:autofillHints= "" android:gravity="center " app:layout_constraintStart_toStartOf="parent " app:layout_constraintEnd_toEndOf="parent " app:layout_constraintTop_toBottomOf="@+id/tvHintUrl " /> <androidx.appcompat.widget.SwitchCompat android:id="@+id/scEditTcp " android:layout_width="wrap_content " android:layout_height="wrap_content " android:layout_marginTop="16dp " android:text="@string/cam_tcp_udp " app:layout_constraintStart_toStartOf="parent " app:layout_constraintEnd_toEndOf="parent " app:layout_constraintTop_toBottomOf="@+id/etEditUrl " /> <Button android:id="@+id/btnSave " android:layout_width="wrap_content " android:layout_height="wrap_content " android:layout_marginTop="16dp " android:padding="10dp " android:text="@string/save " android:background="@color/buttonBackground " android:foreground="Эandroid:attr/selectableItemBackground " app:layout_constraintStart_toStartOf="parent " app:layout_constraintEnd_toEndOf="parent " app:layout_constraintTop_toBottomOf="@+id/scEditTcp " /> <TextView android:id="@+id/tvDeleteLink " android:layout_width="wrap_content " android:layout_height="wrap_content " android:text="@string/delete " android:layout_marginTop="18dp " android:padding="10dp " android:textColor="@color/error " android:clickable="true " android:focusable="true " android:background="Эattr/selectableItemBackground " android:visibility="gone " app:layout_constraintEnd_toEndOf="parent " app:layout_constraintTop_toBottomOf="@+id/scEditTcp " /> </androidx.constraintlayout.widget.ConstraintLayout>

Касание видео (VideoActivity) инициализирует медиаплеер (MediaPlayer(libVlc)) и добавляет необходимые параметры --rtsp-tcp и network-caching. К сожалению, не существует рекомендуемого набора опций, благодаря которым плеер будет работать «хорошо».

Значение параметра сетевого кэширования было выбрано опытным путем.

Слишком низкое значение может привести к тому, что видеопоток не будет отображаться; слишком высокое значение приведет к увеличению задержки перед воспроизведением.

VideoActivity.kt

package com.vladpen.cams import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.* import android.view.ScaleGestureDetector.SimpleOnScaleGestureListener import androidx.appcompat.app.AppCompatActivity import com.vladpen.StreamData import com.vladpen.cams.databinding.ActivityVideoBinding import org.videolan.libvlc.LibVLC import org.videolan.libvlc.Media import org.videolan.libvlc.MediaPlayer import org.videolan.libvlc.util.VLCVideoLayout import java.io.IOException import kotlin.math.max import kotlin.math.min class VideoActivity : AppCompatActivity(), MediaPlayer.EventListener { private val binding by lazy { ActivityVideoBinding.inflate(layoutInflater) } private lateinit var libVlc: LibVLC private lateinit var mediaPlayer: MediaPlayer private lateinit var videoLayout: VLCVideoLayout private lateinit var scaleGestureDetector: ScaleGestureDetector private var scaleFactor = 1.0f private var position: Int = -1 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) initActivity() } private fun initActivity() { position = intent.getIntExtra("position", -1) val stream = StreamData.getByPosition(position) if (stream == null) { position = -1 return } binding.toolbar.tvToolbarLabel.text = stream.name binding.toolbar.btnBack.setOnClickListener { val mainIntent = Intent(this, MainActivity::class.java) startActivity(mainIntent) } videoLayout = binding.videoLayout libVlc = LibVLC(this, ArrayList<String>().

apply { if (stream.tcp) { add("--rtsp-tcp") } }) mediaPlayer = MediaPlayer(libVlc) mediaPlayer.setEventListener(this) mediaPlayer.attachViews(videoLayout, null, false, false) try { val uri = Uri.parse(stream.url) Media(libVlc, uri).

apply { setHWDecoderEnabled(true, false) addOption(":network-caching=150") mediaPlayer.media = this }.

release() mediaPlayer.play() } catch (e: IOException) { e.printStackTrace() } scaleGestureDetector = ScaleGestureDetector(this, ScaleListener()) } override fun onStop() { super.onStop() mediaPlayer.stop() mediaPlayer.detachViews() } override fun onDestroy() { super.onDestroy() mediaPlayer.release() libVlc.release() } override fun onEvent(ev: MediaPlayer.Event) { if (ev.type == MediaPlayer.Event.Buffering && ev.buffering == 100f) { binding.pbLoading.visibility = View.GONE } } override fun onTouchEvent(ev: MotionEvent): Boolean { // Let the ScaleGestureDetector inspect all events. scaleGestureDetector.onTouchEvent(ev) return true } inner class ScaleListener : SimpleOnScaleGestureListener() { override fun onScale(scaleGestureDetector: ScaleGestureDetector): Boolean { scaleFactor *= scaleGestureDetector.scaleFactor scaleFactor = max(1f, min(scaleFactor, 10.0f)) videoLayout.scaleX = scaleFactor videoLayout.scaleY = scaleFactor return true } } }

Activity_video.xml

<Эxml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android " xmlns:app="http://schemas.android.com/apk/res-auto " xmlns:tools="http://schemas.android.com/tools " android:layout_width="match_parent " android:layout_height="match_parent " tools:context=".

VideoActivity "> <org.videolan.libvlc.util.VLCVideoLayout android:id="@+id/videoLayout " android:layout_width="match_parent " android:layout_height="wrap_content " app:layout_constraintTop_toTopOf="parent " app:layout_constraintStart_toStartOf="parent " app:layout_constraintBottom_toBottomOf="parent " app:layout_constraintEnd_toEndOf="parent " /> <ProgressBar android:id="@+id/pbLoading " android:layout_width="wrap_content " android:layout_height="wrap_content " android:indeterminate="true " app:layout_constraintTop_toTopOf="parent " app:layout_constraintStart_toStartOf="parent " app:layout_constraintBottom_toBottomOf="parent " app:layout_constraintEnd_toEndOf="parent " /> <include android:id="@+id/toolbar " layout="@layout/toolbar"/> </androidx.constraintlayout.widget.ConstraintLayout>

Видеотап дополнительно реализует интерфейс MediaPlayer.EventListener, который нужен для отключения индикатора загрузки (pbLoading) после окончания буферизации потока.

Внутренний класс ScaleListener обрабатывает жест масштабирования.

Заголовки экранов я вынес в отдельный файл, который включается в разметку экрана директивой include: панель инструментов.

xml

<Эxml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android " xmlns:app="http://schemas.android.com/apk/res-auto " android:id="@+id/toolbar " android:layout_width="match_parent " android:layout_height="wrap_content " android:background="@color/overlay_background "> <ImageButton android:id="@+id/btnBack " android:layout_width="wrap_content " android:layout_height="wrap_content " android:background="@color/transparent_background " android:foreground="Эandroid:attr/selectableItemBackground " android:padding="10dp " android:src="@drawable/ic_baseline_arrow_back_24 " android:contentDescription="@string/back " app:layout_constraintStart_toStartOf="parent " app:layout_constraintTop_toTopOf="parent " app:layout_constraintBottom_toBottomOf="parent " /> <TextView android:id="@+id/tvToolbarLabel " android:layout_width="wrap_content " android:layout_height="wrap_content " android:layout_marginStart="16dp " android:textColor="@android:color/white " android:textSize="20sp " android:textStyle="bold " app:layout_constraintBottom_toBottomOf="parent " app:layout_constraintStart_toEndOf="@+id/btnBack " app:layout_constraintTop_toTopOf="parent " /> <TextView android:id="@+id/tvToolbarLink " android:layout_width="wrap_content " android:layout_height="wrap_content " android:padding="10dp " android:layout_marginEnd="6dp " android:textColor="@color/hint " android:clickable="true " android:focusable="true " android:background="Эattr/selectableItemBackground " android:visibility="gone " app:layout_constraintTop_toTopOf="parent " app:layout_constraintBottom_toBottomOf="parent " app:layout_constraintEnd_toEndOf="parent " /> </androidx.constraintlayout.widget.ConstraintLayout>

В результате приложение получилось если не максимально простым, то хотя бы максимально близким к нему :)

Сборка

Хотя нативные приложения имеют минимальный размер (и максимальную производительность), использование библиотеки libvlc-all увеличивает итоговый размер сборки:

Удаленный доступ к IP-камерам.
</p><p>
 Часть 2. Мобильное приложение

Как видите, поддержка каждой платформы съедает около 19 МБ дискового пространства.

Такова цена «всеядности» VLC, который работает практически всегда и везде и воспроизводит все, во что вообще можно играть.



ДЕЛАТЬ

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

Кроме того, чуть позже планирую добавить поддержку воспроизведения сохраненного архива по SFTP.

Вместо заключения

В результате моих исследований у меня получилось простое, но вполне рабочее приложение.

Теги: #Android #разработка под Android #мобильные приложения #Kotlin #видеонаблюдение

Вместе с данным постом часто просматривают:

Автор Статьи


Зарегистрирован: 2019-12-10 15:07:06
Баллов опыта: 0
Всего постов на сайте: 0
Всего комментарий на сайте: 0
Dima Manisha

Dima Manisha

Эксперт Wmlog. Профессиональный веб-мастер, SEO-специалист, дизайнер, маркетолог и интернет-предприниматель.