Comments added, code cleared
This commit is contained in:
@@ -4,31 +4,36 @@ import android.content.Context
|
|||||||
import android.graphics.*
|
import android.graphics.*
|
||||||
import android.text.TextPaint
|
import android.text.TextPaint
|
||||||
import android.util.AttributeSet
|
import android.util.AttributeSet
|
||||||
import android.util.Log
|
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Класс наследован от SurfaceView
|
||||||
|
* В нем отрисовывается проход фразы Hello World за 20 кадров
|
||||||
|
* в превью можно переключать кадры, меняя _frameNumber от 0 до 19
|
||||||
|
* В режиме редактирования для удобной отладки строка заполняется красным фоном
|
||||||
|
*
|
||||||
|
*/
|
||||||
class HelloSurface : SurfaceView, SurfaceHolder.Callback {
|
class HelloSurface : SurfaceView, SurfaceHolder.Callback {
|
||||||
|
|
||||||
private var _helloWorldString: String = ""
|
private var _helloWorldString: String = "" //Строка для отображения
|
||||||
private var _textSize: Float = 100f
|
private var _frameNumber: Int = 7 // Инициализация номера фрейма, видно на превью
|
||||||
private var _frameNumber: Int = 7
|
private var _frameRate: Int = 30 //Частота кадров
|
||||||
|
|
||||||
private lateinit var textPaint: TextPaint
|
private lateinit var textPaint: TextPaint
|
||||||
private lateinit var backgroundPaint: Paint
|
private lateinit var backgroundPaint: Paint //Фон надписи в режиме редактирвоания
|
||||||
private var textWidth: Float = 0f
|
private var textWidth: Float = 0f
|
||||||
private var textHeight: Float = 0f
|
private var textHeight: Float = 0f
|
||||||
private var textPositionVertical = 0f
|
|
||||||
private var frameDelta = 0f
|
|
||||||
|
|
||||||
private var mainJob: Job? = null
|
private var mainJob: Job? = null
|
||||||
private var persistentSurface: Surface? = null
|
|
||||||
|
private var persistentSurface: Surface? = null //Surface для рендера и сохранения видео
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The text to draw
|
* Текст для отрисовки
|
||||||
*/
|
*/
|
||||||
var helloWorldString: String
|
var helloWorldString: String
|
||||||
get() = _helloWorldString
|
get() = _helloWorldString
|
||||||
@@ -38,22 +43,24 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* font Size
|
* Частота кадров
|
||||||
*/
|
*/
|
||||||
var textSize: Float
|
var frameRate: Int
|
||||||
get() = _textSize
|
get() = _frameRate
|
||||||
set(value) {
|
set(value) {
|
||||||
_textSize = value
|
_frameRate = value
|
||||||
invalidateTextPaintAndMeasurements()
|
|
||||||
}
|
}
|
||||||
var frameNumber: Int
|
|
||||||
|
/**
|
||||||
|
* Номер фрейма, используется только в режиме редактирования для превью
|
||||||
|
*/
|
||||||
|
var frameNumber: Int
|
||||||
get() = _frameNumber
|
get() = _frameNumber
|
||||||
set(value) {
|
set(value) {
|
||||||
_frameNumber = value % 20
|
_frameNumber = value % 20
|
||||||
invalidateTextPaintAndMeasurements()
|
invalidateTextPaintAndMeasurements()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
holder.addCallback(this)
|
holder.addCallback(this)
|
||||||
}
|
}
|
||||||
@@ -75,20 +82,14 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun attrsInit(attrs: AttributeSet?, defStyle: Int) {
|
private fun attrsInit(attrs: AttributeSet?, defStyle: Int) {
|
||||||
// Load attributes
|
|
||||||
val a = context.obtainStyledAttributes(
|
val a = context.obtainStyledAttributes(
|
||||||
attrs, R.styleable.TestView, defStyle, 0
|
attrs, R.styleable.TestView, defStyle, 0
|
||||||
)
|
)
|
||||||
|
|
||||||
_helloWorldString = a.getString(R.styleable.TestView_helloString) ?: "Test String"
|
_helloWorldString = a.getString(R.styleable.TestView_helloString) ?: "Test String"
|
||||||
_textSize = a.getDimension(
|
_frameRate = a.getInteger(R.styleable.TestView_frameRate, 30)
|
||||||
R.styleable.TestView_textSize,
|
_frameNumber = a.getInteger(R.styleable.TestView_frameNumber, frameNumber)
|
||||||
textSize
|
|
||||||
)
|
|
||||||
_frameNumber = a.getInteger(
|
|
||||||
R.styleable.TestView_frameNumber,
|
|
||||||
frameNumber
|
|
||||||
)
|
|
||||||
a.recycle()
|
a.recycle()
|
||||||
|
|
||||||
textPaint = TextPaint().apply {
|
textPaint = TextPaint().apply {
|
||||||
@@ -103,17 +104,20 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback {
|
|||||||
|
|
||||||
invalidateTextPaintAndMeasurements()
|
invalidateTextPaintAndMeasurements()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setExternalSurface(surface: Surface) {
|
fun setExternalSurface(surface: Surface) {
|
||||||
persistentSurface = surface
|
persistentSurface = surface
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перегруженный onDraw остался только для превью
|
||||||
|
*/
|
||||||
override fun onDraw(canvas: Canvas?) {
|
override fun onDraw(canvas: Canvas?) {
|
||||||
super.onDraw(canvas)
|
super.onDraw(canvas)
|
||||||
if (isInEditMode)
|
if (isInEditMode)
|
||||||
canvas?.let {
|
canvas?.let {
|
||||||
drawHello(canvas, _frameNumber)
|
drawHello(canvas, _frameNumber)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||||
@@ -121,38 +125,46 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback {
|
|||||||
}
|
}
|
||||||
private fun invalidateTextPaintAndMeasurements() {
|
private fun invalidateTextPaintAndMeasurements() {
|
||||||
val textBounds = Rect()
|
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
|
textHeight = textBounds.height()+0f
|
||||||
textWidth = textBounds.width()+0f
|
textWidth = textBounds.width()+0f
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выделение красным фоном надписи в превью
|
||||||
|
*/
|
||||||
private fun drawBounds(canvas: Canvas, sx: Int, sy: Int) {
|
private fun drawBounds(canvas: Canvas, sx: Int, sy: Int) {
|
||||||
val textBounds = Rect()
|
val textBounds = Rect()
|
||||||
textPaint.getTextBounds(helloWorldString, 0, helloWorldString.length, textBounds)
|
textPaint.getTextBounds(_helloWorldString, 0, _helloWorldString.length, textBounds)
|
||||||
textBounds.right = textPaint.measureText(helloWorldString).toInt()
|
textBounds.right = textPaint.measureText(_helloWorldString).toInt()
|
||||||
textBounds.offsetTo(sx, sy-textHeight.toInt()+textPaint.descent().toInt())
|
textBounds.offsetTo(sx, sy-textHeight.toInt()+textPaint.descent().toInt())
|
||||||
canvas.drawRect(textBounds, backgroundPaint)
|
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
|
val textSize = canvas.height/20f
|
||||||
textPaint.textSize = textSize
|
textPaint.textSize = textSize
|
||||||
textWidth = textPaint.measureText(helloWorldString)
|
textWidth = textPaint.measureText(_helloWorldString)
|
||||||
|
|
||||||
textPositionVertical = canvas.height/2f+textSize/2f
|
val textPositionVertical = canvas.height/2f+textSize/2f
|
||||||
frameDelta = (canvas.width+textWidth)/20f
|
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) {
|
private fun clearCanvas(canvas: Canvas) {
|
||||||
@@ -165,20 +177,31 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback {
|
|||||||
renderLoop()
|
renderLoop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Volatile private var maxFramesRender = 120
|
@Volatile private var maxFramesRender = 120
|
||||||
@Volatile private var framesRendered = -1
|
@Volatile private var framesRendered = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Метод, запускающий отсчет фреймов для записи
|
||||||
|
*/
|
||||||
fun startRecording(maxFrames: Int) {
|
fun startRecording(maxFrames: Int) {
|
||||||
maxFramesRender = maxFrames
|
maxFramesRender = maxFrames
|
||||||
framesRendered++
|
framesRendered++
|
||||||
}
|
}
|
||||||
var onLastFrame : () -> Unit = @Synchronized {}
|
|
||||||
|
/**
|
||||||
|
* Метод, который будет вызван из корутины после рендера последнего фрейма для записи
|
||||||
|
* Он будет переназначен во время инициализации енкодера
|
||||||
|
*/
|
||||||
|
var onLastFrame : () -> Unit = {}
|
||||||
|
|
||||||
fun setOnLastFrameRecordedListener(listener: () -> Unit) {
|
fun setOnLastFrameRecordedListener(listener: () -> Unit) {
|
||||||
onLastFrame = listener
|
onLastFrame = listener
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отрисовка превью и здесь же рендер фреймов
|
||||||
|
*/
|
||||||
private suspend fun renderLoop() {
|
private suspend fun renderLoop() {
|
||||||
while(GlobalScope.isActive) {
|
while(GlobalScope.isActive) {
|
||||||
val timeInMillis = measureTimeMillis {
|
val timeInMillis = measureTimeMillis {
|
||||||
@@ -193,13 +216,15 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback {
|
|||||||
preview()
|
preview()
|
||||||
|
|
||||||
_frameNumber++
|
_frameNumber++
|
||||||
}
|
|
||||||
if (_frameNumber >= 20) _frameNumber = 0
|
if (_frameNumber >= 20) _frameNumber = 0
|
||||||
delay(33-timeInMillis)
|
}
|
||||||
|
//Расчет паузы перед отрисовкой следующего фрейма
|
||||||
|
delay(1000/_frameRate-timeInMillis)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun render() {
|
//Рендер фрейма для записи видео
|
||||||
|
private fun render() {
|
||||||
persistentSurface?.let {surface ->
|
persistentSurface?.let {surface ->
|
||||||
val pCanvas = surface.lockCanvas(null)
|
val pCanvas = surface.lockCanvas(null)
|
||||||
pCanvas?.let {
|
pCanvas?.let {
|
||||||
@@ -211,8 +236,8 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
//Такой же рендер, но видимый на View и работающий постоянно
|
||||||
fun preview() {
|
private fun preview() {
|
||||||
val canvas = holder.lockCanvas()
|
val canvas = holder.lockCanvas()
|
||||||
canvas?.let {
|
canvas?.let {
|
||||||
clearCanvas(it)
|
clearCanvas(it)
|
||||||
@@ -224,7 +249,7 @@ class HelloSurface : SurfaceView, SurfaceHolder.Callback {
|
|||||||
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
//Остановка корутины при уничтожении surface
|
||||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
mainJob?.cancelAndJoin()
|
mainJob?.cancelAndJoin()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<su.rst10h.loopedworld.TestView
|
<su.rst10h.loopedworld.HelloSurface
|
||||||
style="@style/Widget.Theme.Inspiry.MyView"
|
style="@style/Widget.Theme.Inspiry.MyView"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
android:paddingBottom="40dp"
|
android:paddingBottom="40dp"
|
||||||
app:frameNumber="8"
|
app:frameNumber="8"
|
||||||
app:helloString="Hello World"
|
app:helloString="Hello World"
|
||||||
app:textSize="55sp" />
|
app:frameRate = "30"
|
||||||
|
/>
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<declare-styleable name="TestView">
|
<declare-styleable name="TestView">
|
||||||
<attr name="helloString" format="string" />
|
<attr name="helloString" format="string" />
|
||||||
<attr name="textSize" format="dimension" />
|
<attr name="frameRate" format="integer" />
|
||||||
<attr name="frameNumber" format="integer" />
|
<attr name="frameNumber" format="integer" />
|
||||||
</declare-styleable>
|
</declare-styleable>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
package su.rst10h.inspiry
|
package su.rst10h.inspiry
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
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.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instrumented test, which will execute on an Android device.
|
* Instrumented test, which will execute on an Android device.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -12,12 +12,23 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Inspiry">
|
android:theme="@style/Theme.Inspiry">
|
||||||
<activity android:name=".ui.MainActivity">
|
<activity android:name=".ui.MainActivity"
|
||||||
|
android:configChanges="orientation"
|
||||||
|
android:screenOrientation="portrait">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="su.rst10h.inspiry.fileprovider"
|
||||||
|
android:grantUriPermissions="true"
|
||||||
|
android:exported="false">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/filepaths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
package su.rst10h.inspiry
|
package su.rst10h.inspiry
|
||||||
|
|
||||||
|
import android.media.MediaFormat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Константы, доступные в любой части приложения.
|
||||||
|
*/
|
||||||
const val MEDIA_FOLDER = "test"
|
const val MEDIA_FOLDER = "test"
|
||||||
const val MEDIA_FILE_NAME = "TestVideo.mp4"
|
const val MEDIA_FILE_NAME = "TestVideo.mp4"
|
||||||
|
const val MEDIA_CODEC = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||||
|
const val MEDIA_FRAME_RATE = 30
|
||||||
@@ -5,6 +5,11 @@ import android.app.Activity
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка разрешений.
|
||||||
|
* Оставил на всякий случай
|
||||||
|
*/
|
||||||
class PermissionsChecker {
|
class PermissionsChecker {
|
||||||
companion object {
|
companion object {
|
||||||
private var REQUEST_EXTERNAL_STORAGE = 1
|
private var REQUEST_EXTERNAL_STORAGE = 1
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import android.media.MediaCodec
|
|||||||
import android.media.MediaCodecInfo
|
import android.media.MediaCodecInfo
|
||||||
import android.media.MediaFormat
|
import android.media.MediaFormat
|
||||||
import android.media.MediaMuxer
|
import android.media.MediaMuxer
|
||||||
import android.util.Log
|
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import su.rst10h.inspiry.MEDIA_CODEC
|
||||||
import su.rst10h.inspiry.MEDIA_FILE_NAME
|
import su.rst10h.inspiry.MEDIA_FILE_NAME
|
||||||
import su.rst10h.inspiry.MEDIA_FOLDER
|
import su.rst10h.inspiry.MEDIA_FOLDER
|
||||||
|
import su.rst10h.inspiry.MEDIA_FRAME_RATE
|
||||||
import su.rst10h.loopedworld.HelloSurface
|
import su.rst10h.loopedworld.HelloSurface
|
||||||
import java.io.File
|
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 bufferInfo = MediaCodec.BufferInfo()
|
||||||
private val mediaEncoder: MediaCodec
|
private val mediaEncoder: MediaCodec
|
||||||
private lateinit var inputSurface: Surface
|
private val inputSurface: Surface
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onStopRecord нужен, чтобы ViewModel узнала о завершении записи
|
||||||
|
*/
|
||||||
|
|
||||||
var onStopRecord = {}
|
var onStopRecord = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализация энкодера
|
||||||
|
* Кодек и частота берется из констант
|
||||||
|
*/
|
||||||
init {
|
init {
|
||||||
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, 1080, 1920)
|
val format = MediaFormat.createVideoFormat(MEDIA_CODEC, 1080, 1920)
|
||||||
format.setInteger(
|
format.setInteger(
|
||||||
MediaFormat.KEY_COLOR_FORMAT,
|
MediaFormat.KEY_COLOR_FORMAT,
|
||||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
|
||||||
)
|
)
|
||||||
format.setInteger(MediaFormat.KEY_BIT_RATE, 2000000)
|
format.setInteger(MediaFormat.KEY_BIT_RATE, 2000000)
|
||||||
format.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
|
format.setInteger(MediaFormat.KEY_FRAME_RATE, MEDIA_FRAME_RATE)
|
||||||
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1)
|
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 5)
|
||||||
mediaEncoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC)
|
mediaEncoder = MediaCodec.createEncoderByType(MEDIA_CODEC)
|
||||||
mediaEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
mediaEncoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
inputSurface = mediaEncoder.createInputSurface()
|
inputSurface = mediaEncoder.createInputSurface()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись на диск осуществляем с помощью MediaMuxer
|
||||||
|
*/
|
||||||
private lateinit var mediaMuxer: MediaMuxer
|
private lateinit var mediaMuxer: MediaMuxer
|
||||||
private var muxerTrackIndex = -1
|
private var muxerTrackIndex = -1
|
||||||
@Volatile var isRunning = false
|
@Volatile var isRunning = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запуск рендера
|
||||||
|
*/
|
||||||
fun startRender() {
|
fun startRender() {
|
||||||
|
|
||||||
muxerTrackIndex = -1
|
muxerTrackIndex = -1
|
||||||
@@ -53,12 +68,17 @@ class VideoEncoder(private val baseFileDir: File, private val helloSurface: Hell
|
|||||||
encode()
|
encode()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Это нужно для остановки энкодера после записи 120 фреймов
|
||||||
|
*/
|
||||||
helloSurface.setOnLastFrameRecordedListener {
|
helloSurface.setOnLastFrameRecordedListener {
|
||||||
Log.d("Encoder", "last frame")
|
|
||||||
isRunning = false
|
isRunning = false
|
||||||
onStopRecord()
|
onStopRecord()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* Запуск отрисовки 120 фреймов в inputSurface энкодера
|
||||||
|
*/
|
||||||
helloSurface.setExternalSurface(inputSurface)
|
helloSurface.setExternalSurface(inputSurface)
|
||||||
helloSurface.startRecording(120)
|
helloSurface.startRecording(120)
|
||||||
}
|
}
|
||||||
@@ -69,37 +89,41 @@ class VideoEncoder(private val baseFileDir: File, private val helloSurface: Hell
|
|||||||
when(encoderStatus) {
|
when(encoderStatus) {
|
||||||
MediaCodec.INFO_TRY_AGAIN_LATER -> { }
|
MediaCodec.INFO_TRY_AGAIN_LATER -> { }
|
||||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||||
Log.d("encoder", "start muxer $muxerTrackIndex")
|
|
||||||
val newFormat: MediaFormat = mediaEncoder.outputFormat
|
val newFormat: MediaFormat = mediaEncoder.outputFormat
|
||||||
muxerTrackIndex = mediaMuxer.addTrack(newFormat)
|
muxerTrackIndex = mediaMuxer.addTrack(newFormat)
|
||||||
mediaMuxer.start()
|
mediaMuxer.start()
|
||||||
}
|
}
|
||||||
-3 -> {
|
-3 -> {
|
||||||
Log.d("encoder", "bad status $encoderStatus")
|
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
/**
|
||||||
|
* если статус >=0, значит что то есть в буфере
|
||||||
|
*/
|
||||||
val data = mediaEncoder.getOutputBuffer(encoderStatus)
|
val data = mediaEncoder.getOutputBuffer(encoderStatus)
|
||||||
data?.let {
|
data?.let {
|
||||||
data.position(bufferInfo.offset)
|
it.position(bufferInfo.offset)
|
||||||
data.limit(bufferInfo.offset + bufferInfo.size)
|
it.limit(bufferInfo.offset + bufferInfo.size)
|
||||||
mediaMuxer.writeSampleData(muxerTrackIndex, data, bufferInfo)
|
mediaMuxer.writeSampleData(muxerTrackIndex, data, bufferInfo)
|
||||||
}
|
}
|
||||||
mediaEncoder.releaseOutputBuffer(encoderStatus, false)
|
mediaEncoder.releaseOutputBuffer(encoderStatus, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.d("encoder","stop encoding")
|
|
||||||
mediaMuxer.stop()
|
mediaMuxer.stop()
|
||||||
mediaMuxer.release()
|
mediaMuxer.release()
|
||||||
mediaEncoder.stop()
|
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).exists()) {
|
||||||
if (!File(fullPath).mkdir()) {
|
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")
|
throw Exception("The test directory does not exist and has not been created:\n$fullPath$\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,51 @@
|
|||||||
package su.rst10h.inspiry.ui
|
package su.rst10h.inspiry.ui
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import su.rst10h.inspiry.MEDIA_FILE_NAME
|
import androidx.core.content.FileProvider
|
||||||
import su.rst10h.inspiry.MEDIA_FOLDER
|
import su.rst10h.inspiry.*
|
||||||
import su.rst10h.inspiry.R
|
|
||||||
import su.rst10h.inspiry.data.ActivityState
|
import su.rst10h.inspiry.data.ActivityState
|
||||||
import su.rst10h.loopedworld.HelloSurface
|
import su.rst10h.loopedworld.HelloSurface
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private val viewModel : MainViewModel by viewModels()
|
||||||
|
private lateinit var helloSurface: HelloSurface
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
//Это нужно на некоторых версиях MIUI для принудительного отключения ночной темы:
|
||||||
|
//На обычных Android устройствах можно обойтись без нее
|
||||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||||
|
|
||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
val viewModel : MainViewModel by viewModels()
|
/**
|
||||||
|
* Так как мы пишем в папку приложения, разрешения можно было не запрашивать
|
||||||
|
* но я не помню, прокатит ли это на Android 7 и ниже, просто на всякий случай:
|
||||||
|
*/
|
||||||
|
PermissionsChecker.verifyStoragePermissions(this)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binding не использовал, он тут роли не сыграет.
|
||||||
|
*/
|
||||||
val btn = findViewById<Button>(R.id.button)
|
val btn = findViewById<Button>(R.id.button)
|
||||||
val surfaceView = findViewById<HelloSurface>(R.id.testView)
|
|
||||||
|
|
||||||
|
helloSurface = findViewById<HelloSurface>(R.id.testView)
|
||||||
|
|
||||||
|
helloSurface.frameRate = MEDIA_FRAME_RATE //Частоту обновления берем из констант
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Наблюдатель за состоянием и действия при изменении состояния:
|
||||||
|
*/
|
||||||
viewModel.state.observe(this, { state ->
|
viewModel.state.observe(this, { state ->
|
||||||
when (state) {
|
when (state) {
|
||||||
ActivityState.WAIT -> {
|
ActivityState.WAIT -> {
|
||||||
@@ -43,25 +62,61 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
ActivityState.OPEN_MEDIA -> {
|
ActivityState.OPEN_MEDIA -> {
|
||||||
openMedia()
|
openMedia()
|
||||||
viewModel.reset_state()
|
viewModel.resetState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
getExternalFilesDir(null)?.let {
|
/**
|
||||||
viewModel.initRender(surfaceView, it)
|
* Инициализация энкодера через ViewModel
|
||||||
|
* Конечно, не лучшее решение передавать View в Repository
|
||||||
|
* Но там он нужен для решения текущей задачи
|
||||||
|
* Тем более HelloSurface специально создан для этих целей
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
getBasePath()?.let {
|
||||||
|
viewModel.initRender(helloSurface, it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Одна кнопка на все действия
|
||||||
|
*/
|
||||||
btn.setOnClickListener {
|
btn.setOnClickListener {
|
||||||
viewModel.action()
|
viewModel.action()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Метод открывает заранее созданный файл
|
||||||
|
* Можно было сделать чуть проще, но на устройствах с Android 11 тогда могут быть проблемы
|
||||||
|
* В гугле такие затейники!
|
||||||
|
*/
|
||||||
private fun openMedia() {
|
private fun openMedia() {
|
||||||
|
|
||||||
|
val f = File(getBasePath(), "/$MEDIA_FOLDER/$MEDIA_FILE_NAME")
|
||||||
|
val fileUri = FileProvider.getUriForFile( this, "${application.packageName}.fileprovider", f)
|
||||||
val intent = Intent(Intent.ACTION_VIEW)
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
intent.setDataAndType(Uri.parse(
|
intent.setDataAndType(fileUri, "video/*")
|
||||||
getExternalFilesDir(null)?.absolutePath+
|
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
"/$MEDIA_FOLDER/$MEDIA_FILE_NAME"),
|
|
||||||
"video/mp4")
|
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Повторная инициализация енкодера
|
||||||
|
*/
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
getBasePath()?.let {
|
||||||
|
viewModel.initRender(helloSurface, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return app folder
|
||||||
|
*/
|
||||||
|
private fun getBasePath(): File? {
|
||||||
|
|
||||||
|
return this.getExternalFilesDir(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,13 @@ import su.rst10h.inspiry.repository.VideoEncoder
|
|||||||
import su.rst10h.loopedworld.HelloSurface
|
import su.rst10h.loopedworld.HelloSurface
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* View Model для главной и единственной Activity
|
||||||
|
*/
|
||||||
class MainViewModel: ViewModel() {
|
class MainViewModel: ViewModel() {
|
||||||
|
/**
|
||||||
|
* MutableLiveData содержит состояние приложения
|
||||||
|
*/
|
||||||
val state by lazy {
|
val state by lazy {
|
||||||
MutableLiveData<ActivityState>().apply {
|
MutableLiveData<ActivityState>().apply {
|
||||||
this.value = WAIT
|
this.value = WAIT
|
||||||
@@ -17,14 +23,23 @@ class MainViewModel: ViewModel() {
|
|||||||
|
|
||||||
private lateinit var videoEncoder: VideoEncoder
|
private lateinit var videoEncoder: VideoEncoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализация энкодера
|
||||||
|
*/
|
||||||
fun initRender(surface: HelloSurface, appPath: File) {
|
fun initRender(surface: HelloSurface, appPath: File) {
|
||||||
videoEncoder = VideoEncoder(appPath, surface)
|
videoEncoder = VideoEncoder(appPath, surface)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset_state() {
|
/**
|
||||||
|
* Сброс состояния
|
||||||
|
*/
|
||||||
|
fun resetState() {
|
||||||
state.postValue(WAIT)
|
state.postValue(WAIT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Действия при нажатии кнопки и смена состояний
|
||||||
|
*/
|
||||||
fun action() {
|
fun action() {
|
||||||
when (state.value) {
|
when (state.value) {
|
||||||
WAIT -> {
|
WAIT -> {
|
||||||
@@ -37,8 +52,6 @@ class MainViewModel: ViewModel() {
|
|||||||
RENDERED -> {
|
RENDERED -> {
|
||||||
state.postValue(OPEN_MEDIA)
|
state.postValue(OPEN_MEDIA)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
android:layout_marginBottom="100dp"
|
android:layout_marginBottom="100dp"
|
||||||
android:elevation="8dp"
|
android:elevation="8dp"
|
||||||
app:frameNumber="0"
|
app:frameNumber="0"
|
||||||
|
app:frameRate="30"
|
||||||
app:helloString="Hello World"
|
app:helloString="Hello World"
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintVertical_bias="1.0"
|
app:layout_constraintVertical_bias="1.0"
|
||||||
app:textSize="40sp" />
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/button"
|
android:id="@+id/button"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
<style name="Theme.Inspiry" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
<style name="Theme.Inspiry" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||||
|
<item name="android:forceDarkAllowed" tools:targetApi="q">false</item>
|
||||||
<!-- Primary brand color. -->
|
<!-- Primary brand color. -->
|
||||||
<item name="colorPrimary">@color/purple_200</item>
|
<item name="colorPrimary">@color/purple_200</item>
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||||
|
|||||||
5
app/src/main/res/xml/filepaths.xml
Normal file
5
app/src/main/res/xml/filepaths.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<files-path path="test/" name="files" />
|
||||||
|
<external-path path="." name="external"/>
|
||||||
|
</paths>
|
||||||
Reference in New Issue
Block a user