mirror of
https://github.com/nextcloud/talk-android
synced 2025-06-19 19:49:33 +01:00
- 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>
397 lines
13 KiB
Kotlin
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()
|
|
}
|
|
}
|