diff --git a/app/src/main/java/app/myzel394/locationtest/services/RecorderService.kt b/app/src/main/java/app/myzel394/locationtest/services/RecorderService.kt index 6cd4173..690f256 100644 --- a/app/src/main/java/app/myzel394/locationtest/services/RecorderService.kt +++ b/app/src/main/java/app/myzel394/locationtest/services/RecorderService.kt @@ -11,6 +11,9 @@ import android.os.Handler import android.os.IBinder import android.os.Looper 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.mutableStateOf import androidx.core.app.NotificationCompat @@ -23,6 +26,7 @@ import com.arthenica.ffmpegkit.ReturnCode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import java.io.File @@ -44,6 +48,7 @@ class RecorderService: Service() { private var mediaRecorder: MediaRecorder? = null private var onError: MediaRecorder.OnErrorListener? = null private var onStateChange: (RecorderState) -> Unit = {} + private var onAmplitudeUpdate: () -> Unit = {} private var counter = 0 @@ -79,19 +84,20 @@ class RecorderService: Service() { return super.onStartCommand(intent, flags, startId) } - val progress: Float - get() { - 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)) + override fun onDestroy() { + super.onDestroy() - return duration / (settings.maxDuration / 1000f) - } + scope.cancel() + } fun setOnErrorListener(onError: MediaRecorder.OnErrorListener) { this.onError = onError } + fun setOnAmplitudeUpdateListener(onAmplitudeUpdate: () -> Unit) { + this.onAmplitudeUpdate = onAmplitudeUpdate + } + fun setOnStateChangeListener(onStateChange: (RecorderState) -> Unit) { this.onStateChange = onStateChange } @@ -133,6 +139,7 @@ class RecorderService: Service() { val amplitude = mediaRecorder!!.maxAmplitude amplitudes.add(amplitude) + onAmplitudeUpdate() handler.postDelayed(::updateAmplitude, AMPLITUDE_UPDATE_INTERVAL) } diff --git a/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/AudioVisualizer.kt b/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/AudioVisualizer.kt index 2789433..a782304 100644 --- a/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/AudioVisualizer.kt +++ b/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/AudioVisualizer.kt @@ -20,7 +20,6 @@ private const val MAX_AMPLITUDE = 10000 @Composable fun AudioVisualizer( amplitudes: List, - showAll: Boolean = false ) { val primary = MaterialTheme.colorScheme.primary val primaryMuted = primary.copy(alpha = 0.3f) @@ -36,17 +35,15 @@ fun AudioVisualizer( translate(width, height) { amplitudes.forEachIndexed { index, amplitude -> val amplitudePercentage = (amplitude.toFloat() / MAX_AMPLITUDE).coerceAtMost(1f) - val boxWidth = if (showAll) width / amplitudes.size else 15f val boxHeight = height * amplitudePercentage drawRoundRect( color = if (amplitudePercentage > 0.05f) primary else primaryMuted, topLeft = Offset( - if (showAll) -width / amplitudes.size * index - else 30f * (index - amplitudes.size), + -width / amplitudes.size * index, -boxHeight / 2f ), - size = Size(boxWidth, boxHeight), + size = Size(width, boxHeight), cornerRadius = CornerRadius(3f, 3f) ) } diff --git a/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/RealTimeAudioVisualizer.kt b/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/RealTimeAudioVisualizer.kt new file mode 100644 index 0000000..6f3a31d --- /dev/null +++ b/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/RealTimeAudioVisualizer.kt @@ -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) + ) + } + } + } + } +} diff --git a/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/RecordingStatus.kt b/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/RecordingStatus.kt index 7dc475b..5923497 100644 --- a/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/RecordingStatus.kt +++ b/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/RecordingStatus.kt @@ -68,7 +68,7 @@ fun RecordingStatus( verticalArrangement = Arrangement.SpaceBetween, ) { Box {} - AudioVisualizer(amplitudes = service.amplitudes) + RealtimeAudioVisualizer(service = service) Column( horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/StartRecording.kt b/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/StartRecording.kt index 13285b3..a7dc1c9 100644 --- a/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/StartRecording.kt +++ b/app/src/main/java/app/myzel394/locationtest/ui/components/AudioRecorder/atoms/StartRecording.kt @@ -1,6 +1,7 @@ package app.myzel394.locationtest.ui.components.AudioRecorder.atoms import android.content.ServiceConnection +import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,6 +24,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -50,6 +53,8 @@ fun StartRecording( if (service != null && service.amplitudes.isNotEmpty()) { Box {} } + + val primary = MaterialTheme.colorScheme.primary Button( onClick = { RecorderService.startService(context, connection) @@ -79,7 +84,7 @@ fun StartRecording( } } if (service != null && service.amplitudes.isNotEmpty()) { - AudioVisualizer(amplitudes = service.amplitudes, showAll = true) + AudioVisualizer(amplitudes = service.amplitudes) } if (service?.originalRecordingStart != null) Button(