From 789c54bd5f245810dd85f917c399cf7c86cee988 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Fri, 25 Nov 2022 16:06:56 +0300 Subject: [PATCH] android: Voice messages playing logic overhaul (#1418) Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/ChatModel.kt | 2 - .../app/views/chat/ComposeVoiceView.kt | 67 +++++----- .../simplex/app/views/chat/SendMsgView.kt | 1 + .../app/views/chat/item/CIVoiceView.kt | 62 +++------- .../simplex/app/views/helpers/RecAndPlay.kt | 115 ++++++++++++------ 5 files changed, 130 insertions(+), 117 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 95e72d1248..53d300fb47 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -1343,8 +1343,6 @@ class CIFile( CIFileStatus.RcvComplete -> true } - val audioInfo: MutableState by lazy { mutableStateOf(ProgressAndDuration()) } - companion object { fun getSample( fileId: Long = 1, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt index 05b0c6d819..f9021a298e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeVoiceView.kt @@ -15,34 +15,42 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.R import chat.simplex.app.ui.theme.* -import chat.simplex.app.views.chat.item.AudioInfoUpdater import chat.simplex.app.views.chat.item.SentColorLight import chat.simplex.app.views.helpers.* import kotlinx.coroutines.flow.distinctUntilChanged @Composable -fun ComposeVoiceView(filePath: String, durationMs: Int, finished: Boolean, cancelEnabled: Boolean, cancelVoice: () -> Unit) { +fun ComposeVoiceView( + filePath: String, + recordedDurationMs: Int, + finishedRecording: Boolean, + cancelEnabled: Boolean, + cancelVoice: () -> Unit +) { BoxWithConstraints(Modifier .fillMaxWidth() ) { val audioPlaying = rememberSaveable { mutableStateOf(false) } - val audioInfo = rememberSaveable(saver = ProgressAndDuration.Saver) { - mutableStateOf(ProgressAndDuration(durationMs = durationMs)) - } - LaunchedEffect(durationMs) { - audioInfo.value = audioInfo.value.copy(durationMs = durationMs) - } + val progress = rememberSaveable { mutableStateOf(0) } + val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) } val progressBarWidth = remember { Animatable(0f) } - LaunchedEffect(durationMs, finished) { - snapshotFlow { audioInfo.value } + LaunchedEffect(recordedDurationMs, finishedRecording) { + snapshotFlow { progress.value } .distinctUntilChanged() .collect { - val number = if (audioPlaying.value) audioInfo.value.progressMs else if (!finished) durationMs else 0 - val new = if (audioPlaying.value || finished) - ((number.toDouble() / durationMs) * maxWidth.value).dp - else - (((number.toDouble()) / MAX_VOICE_MILLIS_FOR_SENDING) * maxWidth.value).dp - progressBarWidth.animateTo(new.value, audioProgressBarAnimationSpec()) + val startTime = when { + audioPlaying.value -> progress.value + finishedRecording && progress.value == duration.value -> progress.value + finishedRecording -> 0 + else -> recordedDurationMs + } + val endTime = when { + finishedRecording -> duration.value + audioPlaying.value -> recordedDurationMs + else -> MAX_VOICE_MILLIS_FOR_SENDING.toInt() + } + val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp + progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec()) } } Spacer( @@ -60,29 +68,26 @@ fun ComposeVoiceView(filePath: String, durationMs: Int, finished: Boolean, cance .background(SentColorLight), verticalAlignment = Alignment.CenterVertically ) { - val play = play@{ - audioPlaying.value = AudioPlayer.start(filePath, audioInfo.value.progressMs) { - audioPlaying.value = false - } - } - val pause = { - audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs) - audioPlaying.value = false - } - AudioInfoUpdater(filePath, audioPlaying, audioInfo) - - IconButton({ if (!audioPlaying.value) play() else pause() }, enabled = finished) { + IconButton( + onClick = { + if (!audioPlaying.value) { + AudioPlayer.play(filePath, audioPlaying, progress, duration) + } else { + AudioPlayer.pause(audioPlaying, progress) + } + }, + enabled = finishedRecording) { Icon( if (audioPlaying.value) Icons.Filled.Pause else Icons.Filled.PlayArrow, stringResource(R.string.icon_descr_file), Modifier .padding(start = 4.dp, end = 2.dp) .size(36.dp), - tint = if (finished) MaterialTheme.colors.primary else HighOrLowlight + tint = if (finishedRecording) MaterialTheme.colors.primary else HighOrLowlight ) } - val numberInText = remember(durationMs, audioInfo.value) { - derivedStateOf { if (audioPlaying.value) audioInfo.value.progressMs / 1000 else durationMs / 1000 } + val numberInText = remember(recordedDurationMs, progress.value) { + derivedStateOf { if (audioPlaying.value) progress.value / 1000 else recordedDurationMs / 1000 } } Text( durationToString(numberInText.value), diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 486b9ea277..541889ed52 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -136,6 +136,7 @@ fun SendMsgView( } val cleanUp = { remove: Boolean -> rec.stop() + AudioPlayer.stop(filePath.value) if (remove) filePath.value?.let { File(it).delete() } filePath.value = null stopRecOnNextClick = false diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt index dbcd0f34cb..fe801eeb75 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt @@ -23,12 +23,10 @@ import androidx.compose.ui.unit.* import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive @Composable fun CIVoiceView( - durationSec: Int, + providedDurationSec: Int, file: CIFile?, edited: Boolean, sent: Boolean, @@ -45,29 +43,21 @@ fun CIVoiceView( val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) } var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) } val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) } - val audioInfo = remember(file.filePath) { - file.audioInfo.value = file.audioInfo.value.copy(durationMs = durationSec * 1000) - file.audioInfo - } - val play = play@{ - audioPlaying.value = AudioPlayer.start(filePath ?: return@play, audioInfo.value.progressMs) { - // If you want to preserve the position after switching a track, remove this line - audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs) - audioPlaying.value = false - } + val progress = rememberSaveable(file.filePath) { mutableStateOf(0) } + val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) } + val play = { + AudioPlayer.play(filePath, audioPlaying, progress, duration, true) brokenAudio = !audioPlaying.value } val pause = { - audioInfo.value = ProgressAndDuration(AudioPlayer.pause(), audioInfo.value.durationMs) - audioPlaying.value = false + AudioPlayer.pause(audioPlaying, progress) } - AudioInfoUpdater(filePath, audioPlaying, audioInfo) - val time = if (audioPlaying.value) audioInfo.value.progressMs else audioInfo.value.durationMs + val time = if (audioPlaying.value) progress.value else duration.value val minWidth = with(LocalDensity.current) { 45.sp.toDp() } val text = durationToString(time / 1000) if (hasText) { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause) + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause) Text( text, Modifier @@ -94,7 +84,7 @@ fun CIVoiceView( ) } Column { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause) + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause) Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { CIMetaView(ci, metaColor) } @@ -103,7 +93,7 @@ fun CIVoiceView( } else { Row { Column { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, audioInfo, brokenAudio, play, pause) + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause) Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { CIMetaView(ci, metaColor) } @@ -124,7 +114,7 @@ fun CIVoiceView( } } } else { - VoiceMsgIndicator(null, false, sent, hasText, null, false, {}, {}) + VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}) val metaReserve = if (edited) " " else @@ -173,15 +163,16 @@ private fun VoiceMsgIndicator( audioPlaying: Boolean, sent: Boolean, hasText: Boolean, - audioInfo: State?, + progress: State?, + duration: State?, error: Boolean, play: () -> Unit, pause: () -> Unit ) { val strokeWidth = with(LocalDensity.current){ 3.dp.toPx() } val strokeColor = MaterialTheme.colors.primary - if (file != null && file.loaded && audioInfo != null) { - val angle = 360f * (audioInfo.value.progressMs.toDouble() / audioInfo.value.durationMs).toFloat() + if (file != null && file.loaded && progress != null && duration != null) { + val angle = 360f * (progress.value.toDouble() / duration.value).toFloat() if (hasText) { IconButton({ if (!audioPlaying) play() else pause() }, Modifier.drawRingModifier(angle, strokeColor, strokeWidth)) { Icon( @@ -242,26 +233,3 @@ private fun ProgressIndicator() { strokeWidth = 4.dp ) } - -@Composable -fun AudioInfoUpdater( - filePath: String?, - audioPlaying: MutableState, - audioInfo: MutableState -) { - LaunchedEffect(filePath) { - if (filePath != null && audioInfo.value.durationMs == 0) { - audioInfo.value = ProgressAndDuration(audioInfo.value.progressMs, AudioPlayer.duration(filePath)) - } - } - LaunchedEffect(audioPlaying.value) { - while (isActive && audioPlaying.value) { - audioInfo.value = AudioPlayer.progressAndDurationOrEnded() - if (audioInfo.value.progressMs == audioInfo.value.durationMs) { - audioInfo.value = ProgressAndDuration(0, audioInfo.value.durationMs) - audioPlaying.value = false - } - delay(50) - } - } -} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt index 605a91521b..76b54ddc40 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt @@ -5,10 +5,10 @@ import android.media.MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED import android.os.Build import android.util.Log import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.Saver import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.ChatItem +import kotlinx.coroutines.* import java.io.* import java.text.SimpleDateFormat import java.util.* @@ -20,19 +20,6 @@ interface Recorder { fun cancel(filePath: String, recordingInProgress: MutableState) } -data class ProgressAndDuration( - val progressMs: Int = 0, - val durationMs: Int = 0 -) { - companion object { - val Saver - get() = Saver, Pair>( - save = { it.value.progressMs to it.value.durationMs }, - restore = { mutableStateOf(ProgressAndDuration(it.first, it.second)) } - ) - } -} - class RecorderNative(private val recordedBytesLimit: Long): Recorder { companion object { // Allows to stop the recorder from outside without having the recorder in a variable @@ -116,55 +103,76 @@ object AudioPlayer { .build() ) } - // Filepath: String, onStop: () -> Unit - private val currentlyPlaying: MutableState Unit>?> = mutableStateOf(null) + // Filepath: String, onProgressUpdate + // onProgressUpdate(null) means stop + private val currentlyPlaying: MutableState Unit>?> = mutableStateOf(null) + private var progressJob: Job? = null - fun start(filePath: String, seek: Int? = null, onStop: () -> Unit): Boolean { + // Returns real duration of the track + private fun start(filePath: String, seek: Int? = null, onProgressUpdate: (position: Int?) -> Unit): Int? { if (!File(filePath).exists()) { Log.e(TAG, "No such file: $filePath") - return false + return null } RecorderNative.stopRecording?.invoke() val current = currentlyPlaying.value if (current == null || current.first != filePath) { + stopListener() player.reset() - // Notify prev audio listener about stop - current?.second?.invoke() runCatching { player.setDataSource(filePath) }.onFailure { Log.e(TAG, it.stackTraceToString()) AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message) - return false + return null } runCatching { player.prepare() }.onFailure { // Can happen when audio file is broken Log.e(TAG, it.stackTraceToString()) AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message) - return false + return null } } if (seek != null) player.seekTo(seek) player.start() - // Repeated calls to play/pause on the same track will not recompose all dependent views - if (currentlyPlaying.value?.first != filePath) { - currentlyPlaying.value = filePath to onStop + currentlyPlaying.value = filePath to onProgressUpdate + progressJob = CoroutineScope(Dispatchers.Default).launch { + onProgressUpdate(player.currentPosition) + while(isActive && player.isPlaying) { + // Even when current position is equal to duration, the player has isPlaying == true for some time, + // so help to make the playback stopped in UI immediately + if (player.currentPosition == player.duration) { + onProgressUpdate(player.currentPosition) + break + } + delay(50) + onProgressUpdate(player.currentPosition) + } + /* + * Since coroutine is still NOT canceled, means player ended (no stop/no pause). But in some cases + * the player can show position != duration even if they actually equal. + * Let's say to a listener that the position == duration in case of coroutine finished without cancel + * */ + if (isActive) { + onProgressUpdate(player.duration) + } + onProgressUpdate(null) } - return true + return player.duration } - fun pause(): Int { + private fun pause(): Int { + progressJob?.cancel() + progressJob = null player.pause() return player.currentPosition } fun stop() { if (!player.isPlaying) return - // Notify prev audio listener about stop - currentlyPlaying.value?.second?.invoke() - currentlyPlaying.value = null player.stop() + stopListener() } fun stop(item: ChatItem) = stop(item.file?.fileName) @@ -176,13 +184,46 @@ object AudioPlayer { } } - /** - * If player starts playing at 2637 ms in a track 2816 ms long (these numbers are just an example), - * it will stop immediately after start but will not change currentPosition, so it will not be equal to duration. - * However, it sets isPlaying to false. Let's do it ourselves in order to prevent endless waiting loop - * */ - fun progressAndDurationOrEnded(): ProgressAndDuration = - ProgressAndDuration(if (player.isPlaying) player.currentPosition else player.duration, player.duration) + private fun stopListener() { + progressJob?.cancel() + progressJob = null + // Notify prev audio listener about stop + currentlyPlaying.value?.second?.invoke(null) + currentlyPlaying.value = null + } + + fun play( + filePath: String?, + audioPlaying: MutableState, + progress: MutableState, + duration: MutableState, + resetOnStop: Boolean = false + ) { + if (progress.value == duration.value) { + progress.value = 0 + } + val realDuration = start(filePath ?: return, progress.value) { pro -> + if (pro != null) { + progress.value = pro + } + if (pro == null || pro == duration.value) { + audioPlaying.value = false + if (resetOnStop) { + progress.value = 0 + } else if (pro == duration.value) { + progress.value = duration.value + } + } + } + audioPlaying.value = realDuration != null + // Update to real duration instead of what was received in ChatInfo + realDuration?.let { duration.value = it } + } + + fun pause(audioPlaying: MutableState, pro: MutableState) { + pro.value = pause() + audioPlaying.value = false + } fun duration(filePath: String): Int { var res = 0