From fb5d89e98767ac8b3b28c9827504951b7e810d05 Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Mon, 13 Nov 2023 12:01:46 +0100 Subject: [PATCH] Added voice record player in chat bubble --- .../ui/main/chat/model/ChatMessageModel.kt | 172 ++++++++++++++++++ .../SendMessageInConversationViewModel.kt | 3 + .../org/linphone/utils/DataBindingUtils.kt | 7 + .../main/res/layout/chat_bubble_incoming.xml | 8 + .../main/res/layout/chat_bubble_outgoing.xml | 8 + .../chat_bubble_voice_record_content.xml | 69 +++++++ ...conversation_record_voice_message_area.xml | 3 - app/src/main/res/values/dimen.xml | 1 + 8 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 app/src/main/res/layout/chat_bubble_voice_record_content.xml diff --git a/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt b/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt index e3e6fe0c8..d221a152a 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/model/ChatMessageModel.kt @@ -26,7 +26,18 @@ import android.util.Patterns import androidx.annotation.UiThread import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData +import androidx.media.AudioFocusRequestCompat +import java.text.SimpleDateFormat +import java.util.Locale import java.util.regex.Pattern +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.R import org.linphone.core.Address @@ -35,9 +46,12 @@ import org.linphone.core.ChatMessageListenerStub import org.linphone.core.ChatMessageReaction import org.linphone.core.Content import org.linphone.core.Factory +import org.linphone.core.Player +import org.linphone.core.PlayerListener import org.linphone.core.tools.Log import org.linphone.ui.main.contacts.model.ContactAvatarModel import org.linphone.utils.AppUtils +import org.linphone.utils.AudioRouteUtils import org.linphone.utils.Event import org.linphone.utils.LinphoneUtils import org.linphone.utils.PatternClickableSpan @@ -105,6 +119,31 @@ class ChatMessageModel @WorkerThread constructor( private lateinit var meetingConferenceUri: Address // End of conference info related fields + // Voice record related fields + val isVoiceRecord = MutableLiveData() + + val isPlayingVoiceRecord = MutableLiveData() + + val voiceRecordPlayerPosition = MutableLiveData() + + val voiceRecordingDuration = MutableLiveData() + + val formattedVoiceRecordingDuration = MutableLiveData() + + private var voiceRecordAudioFocusRequest: AudioFocusRequestCompat? = null + + private lateinit var voiceRecordPath: String + + private lateinit var voiceRecordPlayer: Player + + private val playerListener = PlayerListener { + Log.i("$TAG End of file reached") + stopVoiceRecordPlayer() + } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + // End of voice record related fields + val dismissLongPressMenuEvent: MutableLiveData> by lazy { MutableLiveData>() } @@ -131,6 +170,8 @@ class ChatMessageModel @WorkerThread constructor( } init { + isPlayingVoiceRecord.postValue(false) + chatMessage.addListener(chatMessageListener) statusIcon.postValue(LinphoneUtils.getChatIconResId(chatMessage.state)) updateReactionsList() @@ -169,6 +210,10 @@ class ChatMessageModel @WorkerThread constructor( displayableContentFound = true } "audio" -> { + isVoiceRecord.postValue(true) + voiceRecordPath = path + initVoiceRecordPlayer() + displayableContentFound = true } else -> { } @@ -221,6 +266,17 @@ class ChatMessageModel @WorkerThread constructor( } } + @UiThread + fun togglePlayPauseVoiceRecord() { + coreContext.postOnCoreThread { + if (isPlayingVoiceRecord.value == false) { + startVoiceRecordPlayer() + } else { + pauseVoiceRecordPlayer() + } + } + } + @WorkerThread private fun updateReactionsList() { var reactionsList = "" @@ -375,4 +431,120 @@ class ChatMessageModel @WorkerThread constructor( meetingFound.postValue(true) } } + + @WorkerThread + private fun initVoiceRecordPlayer() { + if (!::voiceRecordPath.isInitialized) { + Log.e("$TAG No voice record path was set!") + return + } + + Log.i("$TAG Creating player for voice record") + + val playbackSoundCard = AudioRouteUtils.getAudioPlaybackDeviceIdForCallRecordingOrVoiceMessage() + Log.i( + "$TAG Using device $playbackSoundCard to make the voice message playback" + ) + + val localPlayer = coreContext.core.createLocalPlayer(playbackSoundCard, null, null) + if (localPlayer != null) { + voiceRecordPlayer = localPlayer + } else { + Log.e("$TAG Couldn't create local player!") + return + } + voiceRecordPlayer.addListener(playerListener) + Log.i("$TAG Voice record player created") + + val path = voiceRecordPath + Log.i("$TAG Opening voice record file [$path]") + voiceRecordPlayer.open(path) + + val duration = voiceRecordPlayer.duration + voiceRecordingDuration.postValue(duration) + val formattedDuration = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration) // duration is in ms + formattedVoiceRecordingDuration.postValue(formattedDuration) + } + + @WorkerThread + private fun startVoiceRecordPlayer() { + if (isPlayerClosed()) { + Log.w("$TAG Player closed, let's open it first") + initVoiceRecordPlayer() + } + + // TODO: check media volume + + if (voiceRecordAudioFocusRequest == null) { + voiceRecordAudioFocusRequest = AudioRouteUtils.acquireAudioFocusForVoiceRecordingOrPlayback( + coreContext.context + ) + } + + Log.i("$TAG Playing voice record") + isPlayingVoiceRecord.postValue(true) + voiceRecordPlayer.start() + + playerTickerFlow().onEach { + withContext(Dispatchers.Main) { + voiceRecordPlayerPosition.value = voiceRecordPlayer.currentPosition + } + }.launchIn(scope) + } + + @WorkerThread + private fun pauseVoiceRecordPlayer() { + if (!isPlayerClosed()) { + Log.i("$TAG Pausing voice record") + voiceRecordPlayer.pause() + } + + val request = voiceRecordAudioFocusRequest + if (request != null) { + AudioRouteUtils.releaseAudioFocusForVoiceRecordingOrPlayback( + coreContext.context, + request + ) + voiceRecordAudioFocusRequest = null + } + + isPlayingVoiceRecord.postValue(false) + } + + @WorkerThread + private fun isPlayerClosed(): Boolean { + return !::voiceRecordPlayer.isInitialized || voiceRecordPlayer.state == Player.State.Closed + } + + @WorkerThread + private fun stopVoiceRecordPlayer() { + if (!isPlayerClosed()) { + Log.i("$TAG Stopping voice record") + voiceRecordPlayer.pause() + voiceRecordPlayer.seek(0) + voiceRecordPlayerPosition.postValue(0) + voiceRecordPlayer.close() + } + + voiceRecordPlayerPosition.postValue(0) + isPlayingVoiceRecord.postValue(false) + + val request = voiceRecordAudioFocusRequest + if (request != null) { + AudioRouteUtils.releaseAudioFocusForVoiceRecordingOrPlayback( + coreContext.context, + request + ) + voiceRecordAudioFocusRequest = null + } + + isPlayingVoiceRecord.postValue(false) + } + + private fun playerTickerFlow() = flow { + while (isPlayingVoiceRecord.value == true) { + emit(Unit) + delay(50) + } + } } diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt index edb97f4f0..733d523dc 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/SendMessageInConversationViewModel.kt @@ -588,6 +588,9 @@ class SendMessageInConversationViewModel @UiThread constructor() : ViewModel() { voiceRecordPlayer.close() } + voiceRecordPlayerPosition.postValue(0) + isPlayingVoiceRecord.postValue(false) + val request = voiceRecordAudioFocusRequest if (request != null) { AudioRouteUtils.releaseAudioFocusForVoiceRecordingOrPlayback( diff --git a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt index 3bbc45d10..040536266 100644 --- a/app/src/main/java/org/linphone/utils/DataBindingUtils.kt +++ b/app/src/main/java/org/linphone/utils/DataBindingUtils.kt @@ -72,6 +72,13 @@ import org.linphone.ui.call.model.ConferenceParticipantDeviceModel * This file contains all the data binding necessary for the app */ +@BindingAdapter("inflatedLifecycleOwner") +fun setInflatedViewStubLifecycleOwner(view: View, enable: Boolean) { + val binding = DataBindingUtil.bind(view) + // This is a bit hacky... + binding?.lifecycleOwner = view.context as AppCompatActivity +} + @UiThread @BindingAdapter("entries", "layout") fun setEntries( diff --git a/app/src/main/res/layout/chat_bubble_incoming.xml b/app/src/main/res/layout/chat_bubble_incoming.xml index e73e0bd1b..a7c228bcc 100644 --- a/app/src/main/res/layout/chat_bubble_incoming.xml +++ b/app/src/main/res/layout/chat_bubble_incoming.xml @@ -176,6 +176,14 @@ android:layout="@layout/chat_bubble_meeting_invite_content" model="@{model}"/> + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/chat_conversation_record_voice_message_area.xml b/app/src/main/res/layout/chat_conversation_record_voice_message_area.xml index f056fcd79..074174ce3 100644 --- a/app/src/main/res/layout/chat_conversation_record_voice_message_area.xml +++ b/app/src/main/res/layout/chat_conversation_record_voice_message_area.xml @@ -5,9 +5,6 @@ - diff --git a/app/src/main/res/values/dimen.xml b/app/src/main/res/values/dimen.xml index 6ad67a65a..0cdc33642 100644 --- a/app/src/main/res/values/dimen.xml +++ b/app/src/main/res/values/dimen.xml @@ -70,6 +70,7 @@ 88dp 150dp 271dp + 271dp 271dp 291dp 5dp