Added simple call recording player

This commit is contained in:
Sylvain Berfini 2024-05-15 17:17:13 +02:00
parent 80da408930
commit 1ea32e7544
3 changed files with 182 additions and 4 deletions

View file

@ -21,9 +21,23 @@ package org.linphone.ui.main.recordings.model
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.ticker
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Factory
import org.linphone.core.Player
import org.linphone.core.PlayerListener
import org.linphone.core.tools.Log
import org.linphone.utils.AudioUtils
import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils
import org.linphone.utils.TimestampUtils
@ -39,7 +53,31 @@ class RecordingModel @WorkerThread constructor(val filePath: String, val fileNam
val dateTime: String
val formattedDuration: String
val duration: Int
val isPlaying = MutableLiveData<Boolean>()
val position = MutableLiveData<Int>()
private var audioFocusRequest: AudioFocusRequestCompat? = null
private lateinit var player: Player
private val playerListener = PlayerListener {
Log.i("$TAG End of file reached")
pause()
player.seek(0)
position.postValue(0)
player.close()
}
private val tickerChannel = ticker(1000, 1000)
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
init {
isPlaying.postValue(false)
val withoutHeader = fileName.substring(LinphoneUtils.RECORDING_FILE_NAME_HEADER.length)
val indexOfSeparator = withoutHeader.indexOf(
LinphoneUtils.RECORDING_FILE_NAME_URI_TIMESTAMP_SEPARATOR
@ -69,6 +107,52 @@ class RecordingModel @WorkerThread constructor(val filePath: String, val fileNam
} else {
sipUri
}
val playbackSoundCard = AudioUtils.getAudioPlaybackDeviceIdForCallRecordingOrVoiceMessage()
val audioPlayer = coreContext.core.createLocalPlayer(playbackSoundCard, null, null)
if (audioPlayer != null) {
player = audioPlayer
player.open(filePath)
player.addListener(playerListener)
duration = player.duration
formattedDuration = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration)
} else {
duration = 0
formattedDuration = "??:??"
}
position.postValue(0)
}
@WorkerThread
fun destroy() {
scope.cancel()
tickerChannel.cancel()
if (::player.isInitialized) {
if (player.state != Player.State.Closed) {
player.close()
}
if (audioFocusRequest != null) {
AudioUtils.releaseAudioFocusForVoiceRecordingOrPlayback(
coreContext.context,
audioFocusRequest!!
)
}
player.removeListener(playerListener)
}
}
@UiThread
fun togglePlayPause() {
coreContext.postOnCoreThread {
if (isPlaying.value == true) {
pause()
} else {
play()
}
}
}
@UiThread
@ -76,4 +160,58 @@ class RecordingModel @WorkerThread constructor(val filePath: String, val fileNam
Log.i("$TAG Deleting call recording [$filePath]")
FileUtils.deleteFile(filePath)
}
@WorkerThread
private fun play() {
if (!::player.isInitialized) return
Log.i("$TAG Starting player, acquiring audio focus")
audioFocusRequest = AudioUtils.acquireAudioFocusForVoiceRecordingOrPlayback(
coreContext.context
)
if (player.state == Player.State.Closed) {
player.open(filePath)
player.seek(0)
}
player.start()
isPlaying.postValue(true)
scope.launch {
withContext(Dispatchers.IO) {
for (tick in tickerChannel) {
withContext(Dispatchers.Main) {
if (player.state == Player.State.Playing) {
updatePosition()
}
}
}
}
}
}
@UiThread
private fun updatePosition() {
val progress = if (player.state == Player.State.Closed) 0 else player.currentPosition
position.value = progress
}
@WorkerThread
private fun pause() {
if (!::player.isInitialized) return
Log.i("$TAG Stopping player, releasing audio focus")
if (audioFocusRequest != null) {
AudioUtils.releaseAudioFocusForVoiceRecordingOrPlayback(
coreContext.context,
audioFocusRequest!!
)
}
coreContext.postOnCoreThread {
player.pause()
}
isPlaying.postValue(false)
}
}

View file

@ -55,6 +55,11 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() {
}
}
override fun onCleared() {
recordings.value.orEmpty().forEach(RecordingModel::destroy)
super.onCleared()
}
@UiThread
fun openSearchBar() {
searchBarVisible.value = true
@ -82,7 +87,7 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() {
@WorkerThread
private fun computeList(filter: String) {
// TODO FIXME: use filter
recordings.value.orEmpty().forEach(RecordingModel::destroy)
val list = arrayListOf<RecordingModel>()
// TODO FIXME: also load recordings from previous Linphone versions

View file

@ -57,10 +57,11 @@
<ImageView
android:id="@+id/play_pause"
android:onClick="@{() -> model.togglePlayPause()}"
android:layout_width="@dimen/icon_size"
android:layout_height="@dimen/icon_size"
android:layout_marginEnd="16dp"
android:src="@drawable/play_fill"
android:src="@{model.isPlaying ? @drawable/pause_fill : @drawable/play_fill, default=@drawable/play_fill}"
app:layout_constraintTop_toTopOf="@id/title"
app:layout_constraintBottom_toBottomOf="@id/title"
app:layout_constraintEnd_toEndOf="parent"
@ -71,7 +72,6 @@
android:id="@+id/time"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:layout_marginTop="3dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
@ -82,9 +82,44 @@
android:ellipsize="end"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/title"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_300"
android:id="@+id/duration"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="3dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:text="@{model.formattedDuration, default=`00:42`}"
android:textSize="12sp"
android:textColor="?attr/color_main2_500"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintTop_toBottomOf="@id/time"
app:layout_constraintBottom_toTopOf="@id/progress"
app:layout_constraintEnd_toEndOf="parent" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="10dp"
android:max="@{model.duration, default=100}"
android:progress="@{model.position, default=75}"
app:trackCornerRadius="5dp"
app:trackThickness="10dp"
app:trackColor="?attr/color_main1_100"
app:indicatorColor="?attr/color_main1_500"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/duration"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>