Added voice record player in chat bubble

This commit is contained in:
Sylvain Berfini 2023-11-13 12:01:46 +01:00
parent f84f42d8bd
commit fb5d89e987
8 changed files with 268 additions and 3 deletions

View file

@ -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<Boolean>()
val isPlayingVoiceRecord = MutableLiveData<Boolean>()
val voiceRecordPlayerPosition = MutableLiveData<Int>()
val voiceRecordingDuration = MutableLiveData<Int>()
val formattedVoiceRecordingDuration = MutableLiveData<String>()
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<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
@ -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)
}
}
}

View file

@ -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(

View file

@ -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<ViewDataBinding>(view)
// This is a bit hacky...
binding?.lifecycleOwner = view.context as AppCompatActivity
}
@UiThread
@BindingAdapter("entries", "layout")
fun <T> setEntries(

View file

@ -176,6 +176,14 @@
android:layout="@layout/chat_bubble_meeting_invite_content"
model="@{model}"/>
<ViewStub
android:id="@+id/voice_record"
android:layout_width="@dimen/chat_bubble_voice_record_width"
android:layout_height="wrap_content"
android:visibility="@{model.isVoiceRecord ? View.VISIBLE : View.GONE, default=gone}"
android:layout="@layout/chat_bubble_voice_record_content"
model="@{model}" />
<org.linphone.ui.main.chat.view.ChatBubbleTextView
style="@style/default_text_style"
android:id="@+id/text_content"

View file

@ -137,6 +137,14 @@
android:layout="@layout/chat_bubble_meeting_invite_content"
model="@{model}"/>
<ViewStub
android:id="@+id/voice_record"
android:layout_width="@dimen/chat_bubble_voice_record_width"
android:layout_height="wrap_content"
android:visibility="@{model.isVoiceRecord ? View.VISIBLE : View.GONE, default=gone}"
android:layout="@layout/chat_bubble_voice_record_content"
model="@{model}" />
<org.linphone.ui.main.chat.view.ChatBubbleTextView
style="@style/default_text_style"
android:id="@+id/text_content"

View file

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View" />
<variable
name="model"
type="org.linphone.ui.main.chat.model.ChatMessageModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="@dimen/chat_bubble_voice_record_width"
android:layout_height="wrap_content"
inflatedLifecycleOwner="@{true}">
<ProgressBar
style="?android:attr/progressBarStyleHorizontal"
android:id="@+id/voice_record_progress"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginStart="10dp"
android:layout_marginEnd="10dp"
android:progressDrawable="@drawable/voice_recording_gradient_progress"
android:progress="@{model.voiceRecordPlayerPosition}"
android:max="@{model.voiceRecordingDuration}"
tools:progress="60"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/play_pause_voice_record"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="10dp"
android:onClick="@{() -> model.togglePlayPauseVoiceRecord()}"
android:padding="8dp"
android:src="@{model.isPlayingVoiceRecord ? @drawable/pause_fill : @drawable/play_fill, default=@drawable/play_fill}"
android:background="@drawable/circle_white_button_background"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintBottom_toBottomOf="@id/voice_record_duration"
app:layout_constraintStart_toStartOf="@id/voice_record_progress"
app:layout_constraintTop_toTopOf="@id/voice_record_duration"
app:tint="@color/orange_main_500" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style"
android:id="@+id/voice_record_duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="5dp"
android:paddingBottom="5dp"
android:text="@{model.formattedVoiceRecordingDuration, default=`00:00`}"
android:textSize="14sp"
android:textColor="@color/gray_main2_600"
android:background="@drawable/shape_squircle_white_r50_background"
app:layout_constraintBottom_toBottomOf="@id/voice_record_progress"
app:layout_constraintEnd_toEndOf="@id/voice_record_progress"
app:layout_constraintTop_toTopOf="@id/voice_record_progress"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -5,9 +5,6 @@
<data>
<import type="android.view.View" />
<variable
name="openFilePickerClickListener"
type="View.OnClickListener" />
<variable
name="viewModel"
type="org.linphone.ui.main.chat.viewmodel.SendMessageInConversationViewModel" />

View file

@ -70,6 +70,7 @@
<dimen name="chat_bubble_grid_image_size">88dp</dimen>
<dimen name="chat_bubble_big_image_max_size">150dp</dimen>
<dimen name="chat_bubble_meeting_invite_width">271dp</dimen>
<dimen name="chat_bubble_voice_record_width">271dp</dimen>
<dimen name="chat_bubble_max_reply_width">271dp</dimen>
<dimen name="chat_bubble_max_width">291dp</dimen>
<dimen name="chat_bubble_images_rounded_corner_radius">5dp</dimen>