В предыдущей статье я рассказывал о простом сервере для работы с камерами видеонаблюдения, но для оперативного просмотра RTSP потоков использовал мобильное приложение VLC, которое меня не вполне устраивало по нескольким причинам. Под катом вы найдете описание и листинги простого мобильного приложения под андроид, написанного специально для охранных камер. Исходники приложения можно взять на github. Для тех, кто не хочет собирать apk самостоятельно, вот ссылка на готовый файл.


Возможно, нам всем сейчас немного не до камер, но Хабр ведь не для политики, верно?


UPD: Похоже, в связи с последними событиями в Google Play, тема импотрозамещения становится еще актуальней.


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


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


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


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


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


Кстати, JetBrains предлагает вроде бы интересное мультиплатформенное решение – Kotlin Multiplatform Mobile. Надо попробовать! Устанавливаю плагин KMM в Android Studio, создаю проект по единственному предложенному шаблону. Структура проекта не нравится. Ладно, может быть можно вынести в shared хотя бы строковые ресурсы? Нет, без танцев с бубном нельзя. А как собрать приложение под iOS? Да никак, для этого нужна iOS. А если учесть, что в стране, где я живу, будущее продукции Apple несколько туманно, смысл теряется окончательно. Решено: буду честно писать под андроид на его официальном языке — котлине.


Реализация


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



Для работы с потоками я буду использовать библиотеку libvlc, настройки сохранять в приватном каталоге во внутреннем хранилище устройства в формате json с помощью библиотеки gson. Для взаимодействия с элементами представления мне нравится view binding, который включается опцией 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'
}

В манифесте, помимо трех activity, нужно не забыть включить разрешение на доступ к сети 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)
    }
}

и элемент списка:


stream_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, формат данных описывает data class 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()
    }
}

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


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


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. К сожалению, не существует рекомендуемого набора опций, при которых плеер будет работать «хорошо». Значение параметра 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>

Экран видео дополнительно реализует (implements) интерфейс MediaPlayer.EventListener, который нужен для отключения индикатора загрузки (pbLoading) после окончания буферизации потока. Внутренний класс ScaleListener обрабатывает жест масштабирования «pinch zoom».


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


toolbar.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, который работает почти всегда и везде и воспроизводит все, что вообще может воспроизводиться.


TODO


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


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


В результате моих исследований получилось простое, но вполне рабочее приложение, поэтому я оставлю его здесь, на Хабре. Надеюсь, кому-нибудь поможет.


P.S. Времени на написание комментариев в коде не было, прошу не судить строго. Зато комментарии открыты на Хабре – добро пожаловать!

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


  1. sisaenkov
    10.03.2022 09:21
    +1

    Есть прекрасное приложение от российского разработчика:
    https://play.google.com/store/apps/details?id=com.alexvas.dvr.pro


    1. vladpen Автор
      10.03.2022 09:26

      Спасибо. Не нашел в описании, переключение протоколов TCP/UDP есть? Подключаться через fallback сильно долго.


      1. sisaenkov
        10.03.2022 09:33

        Есть.


  1. NTDLL
    10.03.2022 09:26

    Когда то распространялось мобильное телевидение от spbtv. Я как раз хотел прикрутить телевидение через vlc и мне посчастливилось найти ссылочки на их потоки. Сейчас не в курсе, работает или нет.


  1. tictac17
    10.03.2022 11:41

    Довольно здорово сделано. Я как-то выкладывал и свое решение для просмотра камер, посмотрите - может чего из него пригодится и вам (https://habr.com/ru/post/598257/). Его плюс в кроссплатформенности и отсутствии необходимости в VLC, минус - нужен простенький сервер.


    1. vladpen Автор
      10.03.2022 12:02

      Спасибо, коллега!)
      Прочитал вашу статью. Действительно, ffmpeg отлично умеет перегонять rtsp в hls. Вот только веб не умеет это воспроизводить в h.265. В этой статье я исследовал возможности обойти это досадное недоразумение.


      1. tictac17
        11.03.2022 10:18

        Думаю отсутствие поддержки h.265 в браузерах дело времени. Но уже сейчас его "умеют" Microsoft Edge (версия 16 и выше) и Safari (версия 11 и выше). Из мобильных браузеров — Safari и Chrome для iOS, (версия 11.0 и выше).


        1. vladpen Автор
          11.03.2022 13:42

          Вопрос времени - да, соглашусь. Но не ближайшего времени точно. По данным caniuse картина совсем не радужная: