Reworked recordings player

This commit is contained in:
Sylvain Berfini 2024-05-16 14:00:24 +02:00
parent 4f848b182a
commit 31580e6291
3 changed files with 147 additions and 106 deletions

View file

@ -73,6 +73,7 @@ class RecordingsFragment : GenericMainFragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = listViewModel binding.viewModel = listViewModel
observeToastEvents(listViewModel)
binding.setBackClickListener { binding.setBackClickListener {
goBack() goBack()

View file

@ -22,27 +22,21 @@ package org.linphone.ui.main.recordings.model
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.media.AudioFocusRequestCompat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale 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.LinphoneApplication.Companion.coreContext
import org.linphone.core.Factory import org.linphone.core.Factory
import org.linphone.core.Player
import org.linphone.core.PlayerListener
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.utils.AudioUtils
import org.linphone.utils.FileUtils import org.linphone.utils.FileUtils
import org.linphone.utils.LinphoneUtils import org.linphone.utils.LinphoneUtils
import org.linphone.utils.TimestampUtils import org.linphone.utils.TimestampUtils
class RecordingModel @WorkerThread constructor(val filePath: String, val fileName: String) { class RecordingModel @WorkerThread constructor(
val filePath: String,
val fileName: String,
private val onPlay: ((model: RecordingModel) -> Unit),
private val onPause: ((model: RecordingModel) -> Unit)
) {
companion object { companion object {
private const val TAG = "[Recording Model]" private const val TAG = "[Recording Model]"
} }
@ -61,20 +55,6 @@ class RecordingModel @WorkerThread constructor(val filePath: String, val fileNam
val position = MutableLiveData<Int>() 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 { init {
isPlaying.postValue(false) isPlaying.postValue(false)
@ -108,13 +88,10 @@ class RecordingModel @WorkerThread constructor(val filePath: String, val fileNam
sipUri sipUri
} }
val playbackSoundCard = AudioUtils.getAudioPlaybackDeviceIdForCallRecordingOrVoiceMessage() val audioPlayer = coreContext.core.createLocalPlayer(null, null, null)
val audioPlayer = coreContext.core.createLocalPlayer(playbackSoundCard, null, null)
if (audioPlayer != null) { if (audioPlayer != null) {
player = audioPlayer audioPlayer.open(filePath)
player.open(filePath) duration = audioPlayer.duration
player.addListener(playerListener)
duration = player.duration
formattedDuration = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration) formattedDuration = SimpleDateFormat("mm:ss", Locale.getDefault()).format(duration)
} else { } else {
duration = 0 duration = 0
@ -125,32 +102,15 @@ class RecordingModel @WorkerThread constructor(val filePath: String, val fileNam
@WorkerThread @WorkerThread
fun destroy() { 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 @UiThread
fun togglePlayPause() { fun togglePlayPause() {
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
if (isPlaying.value == true) { if (isPlaying.value == true) {
pause() onPause.invoke(this)
} else { } else {
play() onPlay.invoke(this)
} }
} }
} }
@ -160,58 +120,4 @@ class RecordingModel @WorkerThread constructor(val filePath: String, val fileNam
Log.i("$TAG Deleting call recording [$filePath]") Log.i("$TAG Deleting call recording [$filePath]")
FileUtils.deleteFile(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

@ -22,10 +22,20 @@ package org.linphone.ui.main.recordings.viewmodel
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.media.AudioFocusRequestCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.ticker
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.R
import org.linphone.core.Player
import org.linphone.core.PlayerListener
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.ui.GenericViewModel import org.linphone.ui.GenericViewModel
import org.linphone.ui.main.recordings.model.RecordingModel import org.linphone.ui.main.recordings.model.RecordingModel
import org.linphone.utils.AudioUtils
import org.linphone.utils.Event import org.linphone.utils.Event
import org.linphone.utils.FileUtils import org.linphone.utils.FileUtils
@ -46,6 +56,18 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() {
MutableLiveData<Event<Boolean>>() MutableLiveData<Event<Boolean>>()
} }
private var audioFocusRequest: AudioFocusRequestCompat? = null
private var currentlyPlayedRecording: RecordingModel? = null
private var player: Player? = null
private val playerListener = PlayerListener {
Log.i("$TAG End of file reached")
stop(currentlyPlayedRecording)
}
private val tickerChannel = ticker(1000, 1000)
init { init {
searchBarVisible.value = false searchBarVisible.value = false
fetchInProgress.value = true fetchInProgress.value = true
@ -56,7 +78,13 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() {
} }
override fun onCleared() { override fun onCleared() {
if (currentlyPlayedRecording != null) {
stop(currentlyPlayedRecording)
player?.removeListener(playerListener)
player = null
}
recordings.value.orEmpty().forEach(RecordingModel::destroy) recordings.value.orEmpty().forEach(RecordingModel::destroy)
super.onCleared() super.onCleared()
} }
@ -85,6 +113,101 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() {
} }
} }
@WorkerThread
fun onRecordingStartedPlaying(model: RecordingModel) {
val lowMediaVolume = AudioUtils.isMediaVolumeLow(coreContext.context)
if (lowMediaVolume) {
Log.w("$TAG Media volume is low, notifying user as they may not hear voice message")
showRedToastEvent.postValue(
Event(Pair(R.string.toast_low_media_volume, R.drawable.speaker_slash))
)
}
if (player == null) {
initAudioPlayer()
}
if (currentlyPlayedRecording != null && model != currentlyPlayedRecording) {
Log.i("$TAG Recording model has changed, stopping player before starting it")
stop(model)
}
currentlyPlayedRecording = model
play(model)
}
@WorkerThread
fun onRecordingPaused(model: RecordingModel) {
pause(model)
}
@WorkerThread
private fun initAudioPlayer() {
Log.i("$TAG Creating player")
val playbackSoundCard = AudioUtils.getAudioPlaybackDeviceIdForCallRecordingOrVoiceMessage()
player = coreContext.core.createLocalPlayer(playbackSoundCard, null, null)
player?.addListener(playerListener)
}
@WorkerThread
private fun play(model: RecordingModel?) {
model ?: return
Log.i("$TAG Starting player")
if (player?.state == Player.State.Closed) {
player?.open(model.filePath)
player?.seek(0)
}
Log.i("$TAG Acquiring audio focus")
audioFocusRequest = AudioUtils.acquireAudioFocusForVoiceRecordingOrPlayback(
coreContext.context
)
player?.start()
model.isPlaying.postValue(true)
viewModelScope.launch {
withContext(Dispatchers.IO) {
for (tick in tickerChannel) {
coreContext.postOnCoreThread {
if (player?.state == Player.State.Playing) {
model.position.postValue(player?.currentPosition)
}
}
}
}
}
}
@WorkerThread
private fun pause(model: RecordingModel?) {
model ?: return
Log.i("$TAG Pausing player, releasing audio focus")
if (audioFocusRequest != null) {
AudioUtils.releaseAudioFocusForVoiceRecordingOrPlayback(
coreContext.context,
audioFocusRequest!!
)
}
player?.pause()
model.isPlaying.postValue(false)
}
@WorkerThread
private fun stop(model: RecordingModel?) {
model ?: return
Log.i("$TAG Stopping player")
pause(model)
model.position.postValue(0)
player?.seek(0)
player?.close()
currentlyPlayedRecording = null
}
@WorkerThread @WorkerThread
private fun computeList(filter: String) { private fun computeList(filter: String) {
recordings.value.orEmpty().forEach(RecordingModel::destroy) recordings.value.orEmpty().forEach(RecordingModel::destroy)
@ -95,7 +218,18 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() {
val path = file.path val path = file.path
val name = file.name val name = file.name
Log.i("$TAG Found file $path") Log.i("$TAG Found file $path")
list.add(RecordingModel(path, name)) list.add(
RecordingModel(
path,
name,
{ model ->
onRecordingStartedPlaying(model)
},
{ model ->
onRecordingPaused(model)
}
)
)
} }
list.sortBy { list.sortBy {