feat: Add smooth scrolling to RealtimeAudioVisualizer

This commit is contained in:
Myzel394 2023-08-05 17:28:27 +02:00
parent f0f20a6594
commit d7500b52db
No known key found for this signature in database
GPG Key ID: 79CC92F37B3E1A2B
5 changed files with 113 additions and 14 deletions

View File

@ -11,6 +11,9 @@ import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@ -23,6 +26,7 @@ import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@ -44,6 +48,7 @@ class RecorderService: Service() {
private var mediaRecorder: MediaRecorder? = null private var mediaRecorder: MediaRecorder? = null
private var onError: MediaRecorder.OnErrorListener? = null private var onError: MediaRecorder.OnErrorListener? = null
private var onStateChange: (RecorderState) -> Unit = {} private var onStateChange: (RecorderState) -> Unit = {}
private var onAmplitudeUpdate: () -> Unit = {}
private var counter = 0 private var counter = 0
@ -79,19 +84,20 @@ class RecorderService: Service() {
return super.onStartCommand(intent, flags, startId) return super.onStartCommand(intent, flags, startId)
} }
val progress: Float override fun onDestroy() {
get() { super.onDestroy()
val start = recordingStart.value ?: return 0f
val now = LocalDateTime.now()
val duration = now.toEpochSecond(ZoneId.systemDefault().rules.getOffset(now)) - start.toEpochSecond(ZoneId.systemDefault().rules.getOffset(start))
return duration / (settings.maxDuration / 1000f) scope.cancel()
} }
fun setOnErrorListener(onError: MediaRecorder.OnErrorListener) { fun setOnErrorListener(onError: MediaRecorder.OnErrorListener) {
this.onError = onError this.onError = onError
} }
fun setOnAmplitudeUpdateListener(onAmplitudeUpdate: () -> Unit) {
this.onAmplitudeUpdate = onAmplitudeUpdate
}
fun setOnStateChangeListener(onStateChange: (RecorderState) -> Unit) { fun setOnStateChangeListener(onStateChange: (RecorderState) -> Unit) {
this.onStateChange = onStateChange this.onStateChange = onStateChange
} }
@ -133,6 +139,7 @@ class RecorderService: Service() {
val amplitude = mediaRecorder!!.maxAmplitude val amplitude = mediaRecorder!!.maxAmplitude
amplitudes.add(amplitude) amplitudes.add(amplitude)
onAmplitudeUpdate()
handler.postDelayed(::updateAmplitude, AMPLITUDE_UPDATE_INTERVAL) handler.postDelayed(::updateAmplitude, AMPLITUDE_UPDATE_INTERVAL)
} }

View File

@ -20,7 +20,6 @@ private const val MAX_AMPLITUDE = 10000
@Composable @Composable
fun AudioVisualizer( fun AudioVisualizer(
amplitudes: List<Int>, amplitudes: List<Int>,
showAll: Boolean = false
) { ) {
val primary = MaterialTheme.colorScheme.primary val primary = MaterialTheme.colorScheme.primary
val primaryMuted = primary.copy(alpha = 0.3f) val primaryMuted = primary.copy(alpha = 0.3f)
@ -36,17 +35,15 @@ fun AudioVisualizer(
translate(width, height) { translate(width, height) {
amplitudes.forEachIndexed { index, amplitude -> amplitudes.forEachIndexed { index, amplitude ->
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f) val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
val boxWidth = if (showAll) width / amplitudes.size else 15f
val boxHeight = height * amplitudePercentage val boxHeight = height * amplitudePercentage
drawRoundRect( drawRoundRect(
color = if (amplitudePercentage > 0.05f) primary else primaryMuted, color = if (amplitudePercentage > 0.05f) primary else primaryMuted,
topLeft = Offset( topLeft = Offset(
if (showAll) -width / amplitudes.size * index -width / amplitudes.size * index,
else 30f * (index - amplitudes.size),
-boxHeight / 2f -boxHeight / 2f
), ),
size = Size(boxWidth, boxHeight), size = Size(width, boxHeight),
cornerRadius = CornerRadius(3f, 3f) cornerRadius = CornerRadius(3f, 3f)
) )
} }

View File

@ -0,0 +1,90 @@
package app.myzel394.locationtest.ui.components.AudioRecorder.atoms
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.unit.dp
import app.myzel394.locationtest.services.RecorderService
import kotlinx.coroutines.launch
private const val MAX_AMPLITUDE = 10000
private const val BOX_WIDTH = 15f
private const val BOX_GAP = 15f
@Composable
fun RealtimeAudioVisualizer(
service: RecorderService,
) {
val scope = rememberCoroutineScope()
val amplitudes = service.amplitudes
val primary = MaterialTheme.colorScheme.primary
val primaryMuted = primary.copy(alpha = 0.3f)
// Moves the visualizer to the left
// A new amplitude is added every 100L milliseconds, so the visualizer moves one
// box width + gap in 100L
val animationProgress = remember { Animatable(0f) }
LaunchedEffect(Unit) {
service.setOnAmplitudeUpdateListener {
scope.launch {
animationProgress.snapTo(0f)
animationProgress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = 100,
easing = LinearEasing
)
)
}
}
}
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
) {
val height = this.size.height / 2f
val width = this.size.width
translate(width, height) {
translate(-animationProgress.value * (BOX_WIDTH + BOX_GAP), 0f) {
amplitudes.forEachIndexed { index, amplitude ->
val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f)
val boxHeight = height * amplitudePercentage
drawRoundRect(
color = if (amplitudePercentage > 0.05f) primary else primaryMuted,
topLeft = Offset(
(BOX_WIDTH + BOX_GAP) * (index - amplitudes.size),
-boxHeight / 2f
),
size = Size(BOX_WIDTH, boxHeight),
cornerRadius = CornerRadius(3f, 3f)
)
}
}
}
}
}

View File

@ -68,7 +68,7 @@ fun RecordingStatus(
verticalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.SpaceBetween,
) { ) {
Box {} Box {}
AudioVisualizer(amplitudes = service.amplitudes) RealtimeAudioVisualizer(service = service)
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {

View File

@ -1,6 +1,7 @@
package app.myzel394.locationtest.ui.components.AudioRecorder.atoms package app.myzel394.locationtest.ui.components.AudioRecorder.atoms
import android.content.ServiceConnection import android.content.ServiceConnection
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -23,6 +24,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@ -50,6 +53,8 @@ fun StartRecording(
if (service != null && service.amplitudes.isNotEmpty()) { if (service != null && service.amplitudes.isNotEmpty()) {
Box {} Box {}
} }
val primary = MaterialTheme.colorScheme.primary
Button( Button(
onClick = { onClick = {
RecorderService.startService(context, connection) RecorderService.startService(context, connection)
@ -79,7 +84,7 @@ fun StartRecording(
} }
} }
if (service != null && service.amplitudes.isNotEmpty()) { if (service != null && service.amplitudes.isNotEmpty()) {
AudioVisualizer(amplitudes = service.amplitudes, showAll = true) AudioVisualizer(amplitudes = service.amplitudes)
} }
if (service?.originalRecordingStart != null) if (service?.originalRecordingStart != null)
Button( Button(