В предыдущем статья Я говорил о чем-то простом сервер для работы с камерами видеонаблюдения, а для быстрого просмотра 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.
Выполнение
Приложение должно быть максимально простым, я не буду (пока) использовать фрагменты и граф навигации.
Экранов у меня будет всего три: список камер, редактор настроек камеры и экран видео:
Для работы с потоками я буду использовать библиотеку libvlc, сохраняя настройки в приватной директории во внутреннем хранилище устройства в формате json с помощью библиотеки gson. Для взаимодействия с элементами представления мне нравится привязка представления, которая включается параметром viewBinding true в файле build.gradle уровня приложения: build.gradle
В манифесте, помимо трех действий, необходимо не забыть включить разрешение на доступ к сети android.permission.INTERNET: AndroidManifest.xmlplugins { 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' }
<Э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 увеличивает итоговый размер сборки:Как видите, поддержка каждой платформы съедает около 19 МБ дискового пространства.
Такова цена «всеядности» VLC, который работает практически всегда и везде и воспроизводит все, во что вообще можно играть.
ДЕЛАТЬ
Поскольку мне нужно было сделать это максимально просто, в эту статью не была включена реализация перемещения увеличенного изображения — это требует определенного количества арифметических вычислений, не добавляющих ясности коду.
Кроме того, чуть позже планирую добавить поддержку воспроизведения сохраненного архива по SFTP.
Вместо заключения
В результате моих исследований у меня получилось простое, но вполне рабочее приложение.Теги: #Android #разработка под Android #мобильные приложения #Kotlin #видеонаблюдение
-
Http-Архив: Веб-Тренды За Девять Месяцев
19 Oct, 24 -
Usb-Адаптер «Y» Своими Руками
19 Oct, 24 -
Сувениры От Eff Для Владельцев Реле Tor
19 Oct, 24