From 80391efc44787fb9391f7b3606fc6996e134c165 Mon Sep 17 00:00:00 2001 From: rst10h Date: Tue, 11 May 2021 16:44:05 +0300 Subject: [PATCH] Comments added, code cleared --- .../su/rst10h/loopedworld/HelloSurface.kt | 127 +++++++++------- .../java/su/rst10h/loopedworld/TestView.kt | 141 ------------------ .../src/main/res/layout/sample_test_view.xml | 5 +- .../src/main/res/values/attrs_test_view.xml | 2 +- .../rst10h/inspiry/ExampleInstrumentedTest.kt | 6 +- app/src/main/AndroidManifest.xml | 13 +- .../main/java/su/rst10h/inspiry/Constants.kt | 9 +- .../su/rst10h/inspiry/PermissionsChecker.kt | 5 + .../rst10h/inspiry/repository/VideoEncoder.kt | 56 +++++-- .../java/su/rst10h/inspiry/ui/MainActivity.kt | 81 ++++++++-- .../su/rst10h/inspiry/ui/MainViewModel.kt | 19 ++- app/src/main/res/layout/activity_main.xml | 3 +- app/src/main/res/values-night/themes.xml | 1 + app/src/main/res/xml/filepaths.xml | 5 + 14 files changed, 239 insertions(+), 234 deletions(-) delete mode 100644 app/loopedworld/src/main/java/su/rst10h/loopedworld/TestView.kt create mode 100644 app/src/main/res/xml/filepaths.xml diff --git a/app/loopedworld/src/main/java/su/rst10h/loopedworld/HelloSurface.kt b/app/loopedworld/src/main/java/su/rst10h/loopedworld/HelloSurface.kt index 5add83e..f01a8b5 100644 --- a/app/loopedworld/src/main/java/su/rst10h/loopedworld/HelloSurface.kt +++ b/app/loopedworld/src/main/java/su/rst10h/loopedworld/HelloSurface.kt @@ -4,31 +4,36 @@ import android.content.Context import android.graphics.* import android.text.TextPaint import android.util.AttributeSet -import android.util.Log import android.view.Surface import android.view.SurfaceHolder import android.view.SurfaceView import kotlinx.coroutines.* import kotlin.system.measureTimeMillis - +/** + * Класс наследован от SurfaceView + * В нем отрисовывается проход фразы Hello World за 20 кадров + * в превью можно переключать кадры, меняя _frameNumber от 0 до 19 + * В режиме редактирования для удобной отладки строка заполняется красным фоном + * + */ class HelloSurface : SurfaceView, SurfaceHolder.Callback { - private var _helloWorldString: String = "" - private var _textSize: Float = 100f - private var _frameNumber: Int = 7 + private var _helloWorldString: String = "" //Строка для отображения + private var _frameNumber: Int = 7 // Инициализация номера фрейма, видно на превью + private var _frameRate: Int = 30 //Частота кадров + private lateinit var textPaint: TextPaint - private lateinit var backgroundPaint: Paint + private lateinit var backgroundPaint: Paint //Фон надписи в режиме редактирвоания private var textWidth: Float = 0f private var textHeight: Float = 0f - private var textPositionVertical = 0f - private var frameDelta = 0f private var mainJob: Job? = null - private var persistentSurface: Surface? = null + + private var persistentSurface: Surface? = null //Surface для рендера и сохранения видео /** - * The text to draw + * Текст для отрисовки */ var helloWorldString: String get() = _helloWorldString @@ -38,22 +43,24 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback { } /** - * font Size + * Частота кадров */ - var textSize: Float - get() = _textSize + var frameRate: Int + get() = _frameRate set(value) { - _textSize = value - invalidateTextPaintAndMeasurements() + _frameRate = value } - var frameNumber: Int + + /** + * Номер фрейма, используется только в режиме редактирования для превью + */ + var frameNumber: Int get() = _frameNumber set(value) { _frameNumber = value % 20 invalidateTextPaintAndMeasurements() } - init { holder.addCallback(this) } @@ -75,20 +82,14 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback { } private fun attrsInit(attrs: AttributeSet?, defStyle: Int) { - // Load attributes + val a = context.obtainStyledAttributes( attrs, R.styleable.TestView, defStyle, 0 ) _helloWorldString = a.getString(R.styleable.TestView_helloString) ?: "Test String" - _textSize = a.getDimension( - R.styleable.TestView_textSize, - textSize - ) - _frameNumber = a.getInteger( - R.styleable.TestView_frameNumber, - frameNumber - ) + _frameRate = a.getInteger(R.styleable.TestView_frameRate, 30) + _frameNumber = a.getInteger(R.styleable.TestView_frameNumber, frameNumber) a.recycle() textPaint = TextPaint().apply { @@ -103,17 +104,20 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback { invalidateTextPaintAndMeasurements() } + fun setExternalSurface(surface: Surface) { persistentSurface = surface } + /** + * Перегруженный onDraw остался только для превью + */ override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) if (isInEditMode) canvas?.let { drawHello(canvas, _frameNumber) } - } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) @@ -121,38 +125,46 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback { } private fun invalidateTextPaintAndMeasurements() { val textBounds = Rect() - textPaint.let { - it.textSize = textSize - } - textPaint.getTextBounds(helloWorldString, 0, helloWorldString.length, textBounds) + + textPaint.getTextBounds(_helloWorldString, 0, _helloWorldString.length, textBounds) textHeight = textBounds.height()+0f textWidth = textBounds.width()+0f } + + /** + * Выделение красным фоном надписи в превью + */ private fun drawBounds(canvas: Canvas, sx: Int, sy: Int) { val textBounds = Rect() - textPaint.getTextBounds(helloWorldString, 0, helloWorldString.length, textBounds) - textBounds.right = textPaint.measureText(helloWorldString).toInt() + textPaint.getTextBounds(_helloWorldString, 0, _helloWorldString.length, textBounds) + textBounds.right = textPaint.measureText(_helloWorldString).toInt() textBounds.offsetTo(sx, sy-textHeight.toInt()+textPaint.descent().toInt()) canvas.drawRect(textBounds, backgroundPaint) } - /** - * todo comment - */ - fun drawHello(canvas: Canvas, frameNumber: Int) { + /** + * Основной метод прорисовки надписи + * Так как этот метод используется для различных размеров Canvas, + * я решил сделать размер шрифта равным 1/20 от высоты Canvas + * + */ + private fun drawHello(canvas: Canvas, frameN: Int) { val textSize = canvas.height/20f textPaint.textSize = textSize - textWidth = textPaint.measureText(helloWorldString) + textWidth = textPaint.measureText(_helloWorldString) - textPositionVertical = canvas.height/2f+textSize/2f - frameDelta = (canvas.width+textWidth)/20f + val textPositionVertical = canvas.height/2f+textSize/2f + val frameDelta = (canvas.width+textWidth)/20f - var xPos = frameNumber*frameDelta-textWidth + val xPos = frameN*frameDelta-textWidth - if (isInEditMode) drawBounds(canvas, xPos.toInt(), textPositionVertical.toInt()) + if (isInEditMode) { + invalidateTextPaintAndMeasurements() + drawBounds(canvas, xPos.toInt(), textPositionVertical.toInt()) + } - canvas.drawText( helloWorldString, xPos, textPositionVertical, textPaint) + canvas.drawText( _helloWorldString, xPos, textPositionVertical, textPaint) } private fun clearCanvas(canvas: Canvas) { @@ -165,20 +177,31 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback { renderLoop() } } + @Volatile private var maxFramesRender = 120 @Volatile private var framesRendered = -1 - + /** + * Метод, запускающий отсчет фреймов для записи + */ fun startRecording(maxFrames: Int) { maxFramesRender = maxFrames framesRendered++ } - var onLastFrame : () -> Unit = @Synchronized {} + + /** + * Метод, который будет вызван из корутины после рендера последнего фрейма для записи + * Он будет переназначен во время инициализации енкодера + */ + var onLastFrame : () -> Unit = {} fun setOnLastFrameRecordedListener(listener: () -> Unit) { onLastFrame = listener } + /** + * Отрисовка превью и здесь же рендер фреймов + */ private suspend fun renderLoop() { while(GlobalScope.isActive) { val timeInMillis = measureTimeMillis { @@ -193,13 +216,15 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback { preview() _frameNumber++ - } if (_frameNumber >= 20) _frameNumber = 0 - delay(33-timeInMillis) + } + //Расчет паузы перед отрисовкой следующего фрейма + delay(1000/_frameRate-timeInMillis) } } - fun render() { + //Рендер фрейма для записи видео + private fun render() { persistentSurface?.let {surface -> val pCanvas = surface.lockCanvas(null) pCanvas?.let { @@ -211,8 +236,8 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback { } } } - - fun preview() { + //Такой же рендер, но видимый на View и работающий постоянно + private fun preview() { val canvas = holder.lockCanvas() canvas?.let { clearCanvas(it) @@ -224,7 +249,7 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback { override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { } - + //Остановка корутины при уничтожении surface override fun surfaceDestroyed(holder: SurfaceHolder) { runBlocking { mainJob?.cancelAndJoin() diff --git a/app/loopedworld/src/main/java/su/rst10h/loopedworld/TestView.kt b/app/loopedworld/src/main/java/su/rst10h/loopedworld/TestView.kt deleted file mode 100644 index 13e9d43..0000000 --- a/app/loopedworld/src/main/java/su/rst10h/loopedworld/TestView.kt +++ /dev/null @@ -1,141 +0,0 @@ -package su.rst10h.loopedworld - -import android.content.Context -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Typeface -import android.text.TextPaint -import android.util.AttributeSet -import android.util.Log -import android.view.SurfaceView -import android.view.View - -/** - * TODO: document your custom view class. - */ -class TestView : View { - - private var _helloWorldString: String = "" - private var _textSize: Float = 100f // TODO: use a default from R.dimen... - private var _frameNumber: Int = 12 - - private lateinit var textPaint: TextPaint - private var textWidth: Float = 0f - private var textHeight: Float = 0f - private var textPositionVertical = 0f - private var frameDelta = 0f - - /** - * The text to draw - */ - var helloWorldString: String - get() = _helloWorldString - set(value) { - _helloWorldString = value - invalidateTextPaintAndMeasurements() - } - - /** - * font Size - */ - var textSize: Float - get() = _textSize - set(value) { - _textSize = value - invalidateTextPaintAndMeasurements() - } - var frameNumber: Int - get() = _frameNumber - set(value) { - _frameNumber = value - invalidateTextPaintAndMeasurements() - } - constructor(context: Context) : super(context) { - init(null, 0) - } - - constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { - init(attrs, 0) - } - - constructor(context: Context, attrs: AttributeSet, defStyle: Int) : super( - context, - attrs, - defStyle - ) { - init(attrs, defStyle) - } - - private fun init(attrs: AttributeSet?, defStyle: Int) { - // Load attributes - val a = context.obtainStyledAttributes( - attrs, R.styleable.TestView, defStyle, 0 - ) - - _helloWorldString = a.getString(R.styleable.TestView_helloString) ?: "Test String Hello" - _textSize = a.getDimension( - R.styleable.TestView_textSize, - textSize - ) - _frameNumber = a.getInteger( - R.styleable.TestView_frameNumber, - frameNumber - ) - a.recycle() - - textPaint = TextPaint().apply { - flags = Paint.ANTI_ALIAS_FLAG - textAlign = Paint.Align.LEFT - typeface = Typeface.DEFAULT_BOLD - } - - - invalidateTextPaintAndMeasurements() - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec) - invalidateTextPaintAndMeasurements() - } - private fun invalidateTextPaintAndMeasurements() { - textPaint.let { - it.textSize = textSize - textWidth = it.measureText(helloWorldString) - textHeight = it.fontMetrics.bottom - } - invalidate() - } - - /** - * todo commen - */ - private fun drawHelloWorld(canvas: Canvas, frameNumber: Int) { - - textPositionVertical = canvas.height/2f+textHeight - frameDelta = (width+textWidth)/20f - var xPos = frameNumber*frameDelta-textWidth -// if (isInEditMode) { -// xPos = width/2f-textWidth/2f -// } - helloWorldString.let { - canvas.drawText( it, xPos, textPositionVertical, textPaint) - } - } - override fun onDraw(canvas: Canvas) { - - super.onDraw(canvas) - - // TODO: consider storing these as member variables to reduce - - val paddingLeft = paddingLeft - val paddingTop = paddingTop - val paddingRight = paddingRight - val paddingBottom = paddingBottom - - val contentWidth = width - paddingLeft - paddingRight - val contentHeight = height - paddingTop - paddingBottom - - drawHelloWorld(canvas, _frameNumber) - - } -} \ No newline at end of file diff --git a/app/loopedworld/src/main/res/layout/sample_test_view.xml b/app/loopedworld/src/main/res/layout/sample_test_view.xml index a985d44..7e6dff3 100644 --- a/app/loopedworld/src/main/res/layout/sample_test_view.xml +++ b/app/loopedworld/src/main/res/layout/sample_test_view.xml @@ -3,7 +3,7 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - + app:frameRate = "30" + /> \ No newline at end of file diff --git a/app/loopedworld/src/main/res/values/attrs_test_view.xml b/app/loopedworld/src/main/res/values/attrs_test_view.xml index 33e34e8..c0b0919 100644 --- a/app/loopedworld/src/main/res/values/attrs_test_view.xml +++ b/app/loopedworld/src/main/res/values/attrs_test_view.xml @@ -1,7 +1,7 @@ - + \ No newline at end of file diff --git a/app/src/androidTest/java/su/rst10h/inspiry/ExampleInstrumentedTest.kt b/app/src/androidTest/java/su/rst10h/inspiry/ExampleInstrumentedTest.kt index 03bba92..caf3bb1 100644 --- a/app/src/androidTest/java/su/rst10h/inspiry/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/su/rst10h/inspiry/ExampleInstrumentedTest.kt @@ -1,13 +1,11 @@ package su.rst10h.inspiry -import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 - +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.Assert.* - /** * Instrumented test, which will execute on an Android device. * diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dd382af..72209fc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,12 +12,23 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Inspiry"> - + + + + \ No newline at end of file diff --git a/app/src/main/java/su/rst10h/inspiry/Constants.kt b/app/src/main/java/su/rst10h/inspiry/Constants.kt index df6e868..79e1c57 100644 --- a/app/src/main/java/su/rst10h/inspiry/Constants.kt +++ b/app/src/main/java/su/rst10h/inspiry/Constants.kt @@ -1,4 +1,11 @@ package su.rst10h.inspiry +import android.media.MediaFormat + +/** + * Константы, доступные в любой части приложения. + */ const val MEDIA_FOLDER = "test" -const val MEDIA_FILE_NAME = "TestVideo.mp4" \ No newline at end of file +const val MEDIA_FILE_NAME = "TestVideo.mp4" +const val MEDIA_CODEC = MediaFormat.MIMETYPE_VIDEO_AVC +const val MEDIA_FRAME_RATE = 30 \ No newline at end of file diff --git a/app/src/main/java/su/rst10h/inspiry/PermissionsChecker.kt b/app/src/main/java/su/rst10h/inspiry/PermissionsChecker.kt index 9b1139b..403845d 100644 --- a/app/src/main/java/su/rst10h/inspiry/PermissionsChecker.kt +++ b/app/src/main/java/su/rst10h/inspiry/PermissionsChecker.kt @@ -5,6 +5,11 @@ import android.app.Activity import android.content.pm.PackageManager import androidx.core.app.ActivityCompat + +/** + * Проверка разрешений. + * Оставил на всякий случай + */ class PermissionsChecker { companion object { private var REQUEST_EXTERNAL_STORAGE = 1 diff --git a/app/src/main/java/su/rst10h/inspiry/repository/VideoEncoder.kt b/app/src/main/java/su/rst10h/inspiry/repository/VideoEncoder.kt index fffc124..75d10a0 100644 --- a/app/src/main/java/su/rst10h/inspiry/repository/VideoEncoder.kt +++ b/app/src/main/java/su/rst10h/inspiry/repository/VideoEncoder.kt @@ -4,12 +4,13 @@ import android.media.MediaCodec import android.media.MediaCodecInfo import android.media.MediaFormat import android.media.MediaMuxer -import android.util.Log import android.view.Surface import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import su.rst10h.inspiry.MEDIA_CODEC import su.rst10h.inspiry.MEDIA_FILE_NAME import su.rst10h.inspiry.MEDIA_FOLDER +import su.rst10h.inspiry.MEDIA_FRAME_RATE import su.rst10h.loopedworld.HelloSurface import java.io.File @@ -17,29 +18,43 @@ class VideoEncoder(private val baseFileDir: File, private val helloSurface: Hell private val bufferInfo = MediaCodec.BufferInfo() private val mediaEncoder: MediaCodec - private lateinit var inputSurface: Surface + private val inputSurface: Surface + + /** + * onStopRecord нужен, чтобы ViewModel узнала о завершении записи + */ var onStopRecord = {} + /** + * Инициализация энкодера + * Кодек и частота берется из констант + */ init { - val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, 1080, 1920) + val format = MediaFormat.createVideoFormat(MEDIA_CODEC, 1080, 1920) format.setInteger( MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface ) format.setInteger(MediaFormat.KEY_BIT_RATE, 2000000) - format.setInteger(MediaFormat.KEY_FRAME_RATE, 30) - format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) - mediaEncoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC) + format.setInteger(MediaFormat.KEY_FRAME_RATE, MEDIA_FRAME_RATE) + format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5) + mediaEncoder = MediaCodec.createEncoderByType(MEDIA_CODEC) mediaEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) inputSurface = mediaEncoder.createInputSurface() } + /** + * Запись на диск осуществляем с помощью MediaMuxer + */ private lateinit var mediaMuxer: MediaMuxer private var muxerTrackIndex = -1 @Volatile var isRunning = false + /** + * Запуск рендера + */ fun startRender() { muxerTrackIndex = -1 @@ -53,12 +68,17 @@ class VideoEncoder(private val baseFileDir: File, private val helloSurface: Hell encode() } + /** + * Это нужно для остановки энкодера после записи 120 фреймов + */ helloSurface.setOnLastFrameRecordedListener { - Log.d("Encoder", "last frame") isRunning = false onStopRecord() } + /** + * Запуск отрисовки 120 фреймов в inputSurface энкодера + */ helloSurface.setExternalSurface(inputSurface) helloSurface.startRecording(120) } @@ -69,37 +89,41 @@ class VideoEncoder(private val baseFileDir: File, private val helloSurface: Hell when(encoderStatus) { MediaCodec.INFO_TRY_AGAIN_LATER -> { } MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> { - Log.d("encoder", "start muxer $muxerTrackIndex") val newFormat: MediaFormat = mediaEncoder.outputFormat muxerTrackIndex = mediaMuxer.addTrack(newFormat) mediaMuxer.start() } -3 -> { - Log.d("encoder", "bad status $encoderStatus") } else -> { - + /** + * если статус >=0, значит что то есть в буфере + */ val data = mediaEncoder.getOutputBuffer(encoderStatus) data?.let { - data.position(bufferInfo.offset) - data.limit(bufferInfo.offset + bufferInfo.size) + it.position(bufferInfo.offset) + it.limit(bufferInfo.offset + bufferInfo.size) mediaMuxer.writeSampleData(muxerTrackIndex, data, bufferInfo) } mediaEncoder.releaseOutputBuffer(encoderStatus, false) } } } - Log.d("encoder","stop encoding") + mediaMuxer.stop() mediaMuxer.release() mediaEncoder.stop() } - private fun prepareFilePath(appPath: File): String { - val fullPath = appPath.absolutePath+"/$MEDIA_FOLDER/" + + /** + * Метод возвращает полный путь с именем файла для записи видео + * Здесь создается каталог test, если не существует + */ + private fun prepareFilePath(path: File): String { + val fullPath = path.absolutePath+"/$MEDIA_FOLDER/" if (!File(fullPath).exists()) { if (!File(fullPath).mkdir()) { - Log.d("main", "directory test not created") throw Exception("The test directory does not exist and has not been created:\n$fullPath$\n") } } diff --git a/app/src/main/java/su/rst10h/inspiry/ui/MainActivity.kt b/app/src/main/java/su/rst10h/inspiry/ui/MainActivity.kt index 69363ea..9f9c8d7 100644 --- a/app/src/main/java/su/rst10h/inspiry/ui/MainActivity.kt +++ b/app/src/main/java/su/rst10h/inspiry/ui/MainActivity.kt @@ -1,32 +1,51 @@ package su.rst10h.inspiry.ui import android.content.Intent -import android.net.Uri import android.os.Bundle import android.widget.Button import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate -import su.rst10h.inspiry.MEDIA_FILE_NAME -import su.rst10h.inspiry.MEDIA_FOLDER -import su.rst10h.inspiry.R +import androidx.core.content.FileProvider +import su.rst10h.inspiry.* import su.rst10h.inspiry.data.ActivityState import su.rst10h.loopedworld.HelloSurface +import java.io.File class MainActivity : AppCompatActivity() { + private val viewModel : MainViewModel by viewModels() + private lateinit var helloSurface: HelloSurface + + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + //Это нужно на некоторых версиях MIUI для принудительного отключения ночной темы: + //На обычных Android устройствах можно обойтись без нее AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + setContentView(R.layout.activity_main) - val viewModel : MainViewModel by viewModels() + /** + * Так как мы пишем в папку приложения, разрешения можно было не запрашивать + * но я не помню, прокатит ли это на Android 7 и ниже, просто на всякий случай: + */ + PermissionsChecker.verifyStoragePermissions(this) + /** + * Binding не использовал, он тут роли не сыграет. + */ val btn = findViewById