talk-android/app/src/main/java/com/nextcloud/talk/ui/MicInputCloud.kt
Julius Linus b1568e7f49
- Fixed bug with occasional crash with stop
- Implement MediaRecorderState handling
- Fixed lifecycle bug - recording and locked UI now ends after app exits
- Saves Waveform to storage after initial loading
- Fixes File caption crashes
- A couple other bugs

Signed-off-by: Julius Linus <julius.linus@nextcloud.com>
2023-11-29 11:38:25 +01:00

397 lines
13 KiB
Kotlin

/*
* Nextcloud Talk application
*
* @author Julius Linus
* Copyright (C) 2023 Julius Linus <julius.linus@nextcloud.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.ui
import android.animation.ValueAnimator
import android.content.Context
import android.content.res.Resources
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Paint.ANTI_ALIAS_FLAG
import android.graphics.Path
import android.graphics.Rect
import android.graphics.drawable.VectorDrawable
import android.util.AttributeSet
import android.view.View
import android.view.animation.LinearInterpolator
import androidx.annotation.ColorInt
import com.nextcloud.talk.R
import kotlin.math.roundToInt
class MicInputCloud(context: Context, attrs: AttributeSet) : View(context, attrs) {
/**
* State Descriptions:
* - PAUSED_STATE: Animation speed is set to zero.
* - PLAY_STATE: Animation speed is set to default, but can be overridden.
*/
enum class ViewState {
/**
* Animation speed is set to zero.
*/
PAUSED_STATE,
/**
* Animation speed is set to default, but can be overridden.
*/
PLAY_STATE
}
@ColorInt
private var primaryColor: Int = Color.WHITE
private var pauseIcon: VectorDrawable? = null
private var playIcon: VectorDrawable? = null
init {
context.theme.obtainStyledAttributes(
attrs,
R.styleable.MicInputCloud,
0,
0
).apply {
try {
pauseIcon = getDrawable(R.styleable.MicInputCloud_pauseIcon) as VectorDrawable
playIcon = getDrawable(R.styleable.MicInputCloud_playIcon) as VectorDrawable
} finally {
recycle()
}
}
}
private var state: ViewState = ViewState.PLAY_STATE
private var ovalOneAnimator: ValueAnimator? = null
private var ovalTwoAnimator: ValueAnimator? = null
private var ovalThreeAnimator: ValueAnimator? = null
private var r1 = OVAL_ONE_DEFAULT_ROTATION
private var r2 = OVAL_TWO_DEFAULT_ROTATION
private var r3 = OVAL_THREE_DEFAULT_ROTATION
private var o1h = OVAL_ONE_DEFAULT_HEIGHT
private var o1w = OVAL_ONE_DEFAULT_WIDTH
private var o2h = OVAL_TWO_DEFAULT_HEIGHT
private var o2w = OVAL_TWO_DEFAULT_WIDTH
private var o3h = OVAL_THREE_DEFAULT_HEIGHT
private var o3w = OVAL_THREE_DEFAULT_WIDTH
private var rotationSpeedMultiplier: Float = DEFAULT_ROTATION_SPEED_MULTIPLIER
private var radius: Float = DEFAULT_RADIUS
private var centerX: Float = 0f
private var centerY: Float = 0f
private val bottomCirclePaint = Paint(ANTI_ALIAS_FLAG).apply {
color = primaryColor
style = Paint.Style.FILL
alpha = DEFAULT_OPACITY
}
private val topCircleBounds = Rect(0, 0, 0, 0)
private val iconBounds = topCircleBounds
override fun onVisibilityChanged(changedView: View, visibility: Int) {
super.onVisibilityChanged(changedView, visibility)
if (visibility == VISIBLE) {
createAnimators()
} else {
state = ViewState.PLAY_STATE
destroyAnimators()
}
}
private fun createAnimators() {
ovalOneAnimator = ValueAnimator.ofInt(
o1h,
OVAL_ONE_DEFAULT_HEIGHT + ANIMATION_CAP,
o1h
).apply {
duration = OVAL_ONE_ANIMATION_LENGTH
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
addUpdateListener { valueAnimator ->
o1h = valueAnimator.animatedValue as Int
}
}
ovalTwoAnimator = ValueAnimator.ofInt(
o2h,
OVAL_TWO_DEFAULT_HEIGHT + ANIMATION_CAP,
o2h
).apply {
duration = OVAL_TWO_ANIMATION_LENGTH
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
addUpdateListener { valueAnimator ->
o2h = valueAnimator.animatedValue as Int
}
}
ovalThreeAnimator = ValueAnimator.ofInt(
o3h,
OVAL_THREE_DEFAULT_HEIGHT + ANIMATION_CAP,
o3h
).apply {
duration = OVAL_THREE_ANIMATION_LENGTH
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
addUpdateListener { valueAnimator ->
o3h = valueAnimator.animatedValue as Int
invalidate() // needed to animate the other listeners as well
}
}
}
private fun destroyAnimators() {
ovalOneAnimator?.cancel()
ovalOneAnimator?.removeAllUpdateListeners()
ovalTwoAnimator?.cancel()
ovalTwoAnimator?.removeAllUpdateListeners()
ovalThreeAnimator?.cancel()
ovalThreeAnimator?.removeAllUpdateListeners()
}
private val circlePath: Path = Path()
private val ovalOnePath: Path = Path()
private val ovalTwoPath: Path = Path()
private val ovalThreePath: Path = Path()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
circlePath.apply {
addCircle(centerX, centerY, DEFAULT_RADIUS, Path.Direction.CCW)
}
ovalOnePath.apply {
addOval(
centerX - (radius + o1w),
centerY - o1h,
centerX + (radius + o1w),
centerY + o1h,
Path.Direction.CCW
)
op(this, circlePath, Path.Op.DIFFERENCE)
}
ovalTwoPath.apply {
addOval(
centerX - (radius + o2w),
centerY - o2h,
centerX + (radius + o2w),
centerY + o2h,
Path.Direction.CCW
)
op(this, circlePath, Path.Op.DIFFERENCE)
}
ovalThreePath.apply {
addOval(
centerX - (radius + o3w),
centerY - o3h,
centerX + (radius + o3w),
centerY + o3h,
Path.Direction.CCW
)
op(this, circlePath, Path.Op.DIFFERENCE)
}
drawMicInputCloud(canvas)
if (state == ViewState.PLAY_STATE) {
r1 += OVAL_ONE_ANIMATION_SPEED * rotationSpeedMultiplier
r2 -= OVAL_TWO_ANIMATION_SPEED * rotationSpeedMultiplier
r3 += OVAL_THREE_ANIMATION_SPEED * rotationSpeedMultiplier
invalidate()
}
}
private fun drawMicInputCloud(canvas: Canvas?) {
canvas?.apply {
save()
rotate(r1, centerX, centerY)
drawPath(ovalOnePath, bottomCirclePaint)
restore()
save()
rotate(r2, centerX, centerY)
drawPath(ovalTwoPath, bottomCirclePaint)
restore()
save()
rotate(r3, centerX, centerY)
drawPath(ovalThreePath, bottomCirclePaint)
restore()
circlePath.reset()
ovalOnePath.reset()
ovalTwoPath.reset()
ovalThreePath.reset()
if (state == ViewState.PLAY_STATE) {
pauseIcon?.apply {
bounds = topCircleBounds
setTint(primaryColor)
draw(canvas)
}
} else {
playIcon?.apply {
bounds = topCircleBounds
setTint(primaryColor)
draw(canvas)
}
}
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = DEFAULT_SIZE.dp
val desiredHeight = DEFAULT_SIZE.dp
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val width: Int = when (widthMode) {
MeasureSpec.EXACTLY -> {
widthSize
}
MeasureSpec.AT_MOST -> {
desiredWidth.coerceAtMost(widthSize)
}
else -> {
desiredWidth
}
}
val height: Int = when (heightMode) {
MeasureSpec.EXACTLY -> {
heightSize
}
MeasureSpec.AT_MOST -> {
desiredHeight.coerceAtMost(heightSize)
}
else -> {
desiredHeight
}
}
centerX = (width / 2).toFloat()
centerY = (height / 2).toFloat()
topCircleBounds.apply {
left = (centerX - DEFAULT_RADIUS).toInt()
top = (centerY - DEFAULT_RADIUS).toInt()
right = (centerX + DEFAULT_RADIUS).toInt()
bottom = (centerY + DEFAULT_RADIUS).toInt()
}
/**
* Drawables are drawn the same way as the canvas is drawn, as both originate from the top-left corner.
* Because of this, the icon's width = (right - left) and height = (bottom - top).
*/
iconBounds.apply {
left = (centerX - DEFAULT_RADIUS + ICON_SIZE.dp).toInt()
top = (centerY - DEFAULT_RADIUS + ICON_SIZE.dp).toInt()
right = (centerX + DEFAULT_RADIUS - ICON_SIZE.dp).toInt()
bottom = (centerY + DEFAULT_RADIUS - ICON_SIZE.dp).toInt()
}
setMeasuredDimension(width, height)
}
override fun performClick(): Boolean {
state = if (state == ViewState.PAUSED_STATE) {
ovalOneAnimator?.resume()
ovalTwoAnimator?.resume()
ovalThreeAnimator?.resume()
ViewState.PLAY_STATE
} else {
ovalOneAnimator?.pause()
ovalTwoAnimator?.pause()
ovalThreeAnimator?.pause()
ViewState.PAUSED_STATE
}
invalidate()
return super.performClick()
}
/**
* Sets the color of the cloud to the parameter, opacity is still set to 50%.
*/
fun setColor(primary: Int) {
primaryColor = primary
bottomCirclePaint.apply {
color = primary
style = Paint.Style.FILL
alpha = DEFAULT_OPACITY
}
invalidate()
}
/**
* Sets state of the component to the parameter, must be of type MicInputCloud.ViewState.
*/
fun setState(s: ViewState) {
state = s
invalidate()
}
/**
* Sets the rotation speed and radius to the parameters, defaults are left unchanged.
*/
fun setRotationSpeed(speed: Float, r: Float) {
rotationSpeedMultiplier = speed
radius = r
invalidate()
}
/**
* Starts the growing and shrinking animation
*/
fun startAnimators() {
ovalOneAnimator?.start()
ovalTwoAnimator?.start()
ovalThreeAnimator?.start()
}
companion object {
val TAG: String? = MicInputCloud::class.simpleName
const val DEFAULT_RADIUS: Float = 70f
const val EXTENDED_RADIUS: Float = 75f
const val MAXIMUM_RADIUS: Float = 80f
const val ICON_SIZE: Int = 9 // Converted to dp this equals about 24dp
private const val DEFAULT_SIZE: Int = 110
private const val DEFAULT_OPACITY: Int = 108
private const val DEFAULT_ROTATION_SPEED_MULTIPLIER: Float = 0.5f
private const val OVAL_ONE_DEFAULT_ROTATION: Float = 105f
private const val OVAL_ONE_DEFAULT_HEIGHT: Int = 85
private const val OVAL_ONE_DEFAULT_WIDTH: Int = 30
private const val OVAL_ONE_ANIMATION_LENGTH: Long = 2000
private const val OVAL_ONE_ANIMATION_SPEED: Float = 2.3f
private const val OVAL_TWO_DEFAULT_ROTATION: Float = 138f
private const val OVAL_TWO_DEFAULT_HEIGHT: Int = 70
private const val OVAL_TWO_DEFAULT_WIDTH: Int = 25
private const val OVAL_TWO_ANIMATION_LENGTH: Long = 1000
private const val OVAL_TWO_ANIMATION_SPEED: Float = 1.75f
private const val OVAL_THREE_DEFAULT_ROTATION: Float = 63f
private const val OVAL_THREE_DEFAULT_HEIGHT: Int = 80
private const val OVAL_THREE_DEFAULT_WIDTH: Int = 40
private const val OVAL_THREE_ANIMATION_LENGTH: Long = 1500
private const val OVAL_THREE_ANIMATION_SPEED: Float = 1f
private const val ANIMATION_CAP: Int = 15
private val Int.dp: Int
get() = (this * Resources.getSystem().displayMetrics.density).roundToInt()
}
}