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