mirror of
https://gitlab.linphone.org/BC/public/linphone-android.git
synced 2026-01-24 07:08:09 +00:00
Updated recordings list & added recording player (audio & video)
This commit is contained in:
parent
37786a0b83
commit
78f1a1e645
11 changed files with 685 additions and 210 deletions
|
|
@ -44,6 +44,10 @@ class RecordingsListAdapter :
|
|||
HeaderAdapter {
|
||||
var selectedAdapterPosition = -1
|
||||
|
||||
val recordingClickedEvent: MutableLiveData<Event<RecordingModel>> by lazy {
|
||||
MutableLiveData<Event<RecordingModel>>()
|
||||
}
|
||||
|
||||
val recordingLongClickedEvent: MutableLiveData<Event<RecordingModel>> by lazy {
|
||||
MutableLiveData<Event<RecordingModel>>()
|
||||
}
|
||||
|
|
@ -74,6 +78,11 @@ class RecordingsListAdapter :
|
|||
binding.apply {
|
||||
lifecycleOwner = parent.findViewTreeLifecycleOwner()
|
||||
|
||||
setOnClickListener {
|
||||
recordingClickedEvent.value = Event(model!!)
|
||||
resetSelection()
|
||||
}
|
||||
|
||||
setOnLongClickListener {
|
||||
selectedAdapterPosition = viewHolder.bindingAdapterPosition
|
||||
root.isSelected = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.main.recordings.fragment
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.TextureView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.linphone.R
|
||||
import org.linphone.core.tools.Log
|
||||
import org.linphone.databinding.RecordingPlayerFragmentBinding
|
||||
import org.linphone.ui.GenericActivity
|
||||
import org.linphone.ui.main.fragment.GenericMainFragment
|
||||
import org.linphone.ui.main.recordings.viewmodel.RecordingMediaPlayerViewModel
|
||||
import org.linphone.utils.AppUtils
|
||||
import org.linphone.utils.FileUtils
|
||||
|
||||
class RecordingMediaPlayerFragment : GenericMainFragment() {
|
||||
companion object {
|
||||
private const val TAG = "[Recording Media Player Fragment]"
|
||||
}
|
||||
|
||||
private lateinit var binding: RecordingPlayerFragmentBinding
|
||||
|
||||
private lateinit var viewModel: RecordingMediaPlayerViewModel
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
binding = RecordingPlayerFragmentBinding.inflate(inflater)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
binding.lifecycleOwner = viewLifecycleOwner
|
||||
|
||||
viewModel = ViewModelProvider(this)[RecordingMediaPlayerViewModel::class.java]
|
||||
binding.viewModel = viewModel
|
||||
observeToastEvents(viewModel)
|
||||
|
||||
binding.setBackClickListener {
|
||||
goBack()
|
||||
}
|
||||
|
||||
binding.setShareClickListener {
|
||||
Log.i("$TAG Sharing call recording [${viewModel.recordingModel.filePath}]")
|
||||
shareFile(viewModel.recordingModel.filePath, viewModel.recordingModel.fileName)
|
||||
}
|
||||
|
||||
binding.setExportClickListener {
|
||||
Log.i("$TAG Saving call recording [${viewModel.recordingModel.filePath}]")
|
||||
exportFile(viewModel.recordingModel.filePath)
|
||||
}
|
||||
|
||||
val model = sharedViewModel.playingRecording
|
||||
if (model != null) {
|
||||
Log.i("$TAG Loading recording [${model.fileName}] from shared view model")
|
||||
viewModel.loadRecording(model)
|
||||
} else {
|
||||
goBack()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
val textureView = binding.videoPlayer
|
||||
if (textureView.isAvailable) {
|
||||
Log.i("$TAG Surface created, setting display in player")
|
||||
viewModel.setVideoRenderingSurface(textureView)
|
||||
} else {
|
||||
Log.i("$TAG Surface not available yet, setting listener")
|
||||
textureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
||||
override fun onSurfaceTextureAvailable(
|
||||
surfaceTexture: SurfaceTexture,
|
||||
p1: Int,
|
||||
p2: Int
|
||||
) {
|
||||
Log.i("$TAG Surface available, setting display in player")
|
||||
viewModel.setVideoRenderingSurface(textureView)
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(
|
||||
surfaceTexture: SurfaceTexture,
|
||||
p1: Int,
|
||||
p2: Int
|
||||
) {
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.play()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
if (viewModel.isPlaying.value == true) {
|
||||
Log.i("$TAG Paused, stopping player")
|
||||
viewModel.pause()
|
||||
}
|
||||
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private fun exportFile(filePath: String) {
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
Log.i(
|
||||
"$TAG Export file [$filePath] to Android's MediaStore"
|
||||
)
|
||||
val mediaStorePath = FileUtils.addContentToMediaStore(filePath)
|
||||
if (mediaStorePath.isNotEmpty()) {
|
||||
Log.i(
|
||||
"$TAG File [$filePath] has been successfully exported to MediaStore"
|
||||
)
|
||||
val message = AppUtils.getString(
|
||||
R.string.toast_file_successfully_exported_to_media_store
|
||||
)
|
||||
(requireActivity() as GenericActivity).showGreenToast(
|
||||
message,
|
||||
R.drawable.check
|
||||
)
|
||||
} else {
|
||||
Log.e(
|
||||
"$TAG Failed to export file [$filePath] to MediaStore!"
|
||||
)
|
||||
val message = AppUtils.getString(
|
||||
R.string.toast_export_file_to_media_store_error
|
||||
)
|
||||
(requireActivity() as GenericActivity).showRedToast(
|
||||
message,
|
||||
R.drawable.warning_circle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareFile(filePath: String, fileName: String) {
|
||||
lifecycleScope.launch {
|
||||
val publicUri = FileProvider.getUriForFile(
|
||||
requireContext(),
|
||||
getString(R.string.file_provider),
|
||||
File(filePath)
|
||||
)
|
||||
Log.i(
|
||||
"$TAG Public URI for file is [$publicUri], starting intent chooser"
|
||||
)
|
||||
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_STREAM, publicUri)
|
||||
putExtra(Intent.EXTRA_SUBJECT, fileName)
|
||||
type = FileUtils.getMimeTypeFromExtension(
|
||||
FileUtils.getExtensionFromFileName(fileName)
|
||||
)
|
||||
}
|
||||
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
startActivity(shareIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import androidx.annotation.UiThread
|
|||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import java.io.File
|
||||
|
|
@ -119,6 +120,18 @@ class RecordingsListFragment : GenericMainFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
adapter.recordingClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { model ->
|
||||
val action = RecordingsListFragmentDirections.actionRecordingsListFragmentToRecordingMediaPlayerFragment()
|
||||
if (findNavController().currentDestination?.id == R.id.recordingsListFragment) {
|
||||
Log.i("$TAG Navigating to recording player for file [${model.filePath}]")
|
||||
sharedViewModel.playingRecording = model
|
||||
|
||||
findNavController().navigate(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
adapter.recordingLongClickedEvent.observe(viewLifecycleOwner) {
|
||||
it.consume { model ->
|
||||
val modalBottomSheet = RecordingsMenuDialogFragment(
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ package org.linphone.ui.main.recordings.model
|
|||
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import org.linphone.LinphoneApplication.Companion.coreContext
|
||||
|
|
@ -34,8 +33,6 @@ import org.linphone.utils.TimestampUtils
|
|||
class RecordingModel @WorkerThread constructor(
|
||||
val filePath: String,
|
||||
val fileName: String,
|
||||
private val onPlay: ((model: RecordingModel) -> Unit),
|
||||
private val onPause: ((model: RecordingModel) -> Unit),
|
||||
isLegacy: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
|
|
@ -56,13 +53,7 @@ class RecordingModel @WorkerThread constructor(
|
|||
|
||||
val duration: Int
|
||||
|
||||
val isPlaying = MutableLiveData<Boolean>()
|
||||
|
||||
val position = MutableLiveData<Int>()
|
||||
|
||||
init {
|
||||
isPlaying.postValue(false)
|
||||
|
||||
if (isLegacy) {
|
||||
val username = fileName.split("_")[0]
|
||||
val sipAddress = coreContext.core.interpretUrl(username, false)
|
||||
|
|
@ -122,22 +113,6 @@ class RecordingModel @WorkerThread constructor(
|
|||
duration = 0
|
||||
formattedDuration = "??:??"
|
||||
}
|
||||
position.postValue(0)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun destroy() {
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun togglePlayPause() {
|
||||
coreContext.postOnCoreThread {
|
||||
if (isPlaying.value == true) {
|
||||
onPause.invoke(this)
|
||||
} else {
|
||||
onPlay.invoke(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2024 Belledonne Communications SARL.
|
||||
*
|
||||
* This file is part of linphone-android
|
||||
* (see https://www.linphone.org).
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.linphone.ui.main.recordings.viewmodel
|
||||
|
||||
import android.view.TextureView
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media.AudioFocusRequestCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.ticker
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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.ui.GenericViewModel
|
||||
import org.linphone.ui.main.recordings.model.RecordingModel
|
||||
import org.linphone.utils.AudioUtils
|
||||
import org.linphone.utils.Event
|
||||
|
||||
class RecordingMediaPlayerViewModel @UiThread constructor() : GenericViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "[Recording Media Player ViewModel]"
|
||||
}
|
||||
|
||||
lateinit var recordingModel: RecordingModel
|
||||
|
||||
lateinit var player: Player
|
||||
|
||||
val isVideo = MutableLiveData<Boolean>()
|
||||
|
||||
val isPlaying = MutableLiveData<Boolean>()
|
||||
|
||||
val position = MutableLiveData<Int>()
|
||||
|
||||
private var audioFocusRequest: AudioFocusRequestCompat? = null
|
||||
|
||||
private val playerListener = PlayerListener {
|
||||
Log.i("$TAG End of file reached")
|
||||
stop()
|
||||
}
|
||||
|
||||
private val tickerChannel = ticker(1000, 1000)
|
||||
private var updatePositionJob: Job? = null
|
||||
|
||||
init {
|
||||
isVideo.value = false
|
||||
isPlaying.value = false
|
||||
position.value = 0
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
if (::recordingModel.isInitialized) {
|
||||
stop()
|
||||
if (::player.isInitialized) {
|
||||
player.removeListener(playerListener)
|
||||
}
|
||||
}
|
||||
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun loadRecording(model: RecordingModel) {
|
||||
recordingModel = model
|
||||
|
||||
coreContext.postOnCoreThread { core ->
|
||||
initPlayer()
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun setVideoRenderingSurface(textureView: TextureView) {
|
||||
coreContext.postOnCoreThread {
|
||||
Log.i("$TAG Setting window ID in player")
|
||||
player.setWindowId(textureView.surfaceTexture)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun initPlayer() {
|
||||
Log.i("$TAG Creating player")
|
||||
val playbackSoundCard = AudioUtils.getAudioPlaybackDeviceIdForCallRecordingOrVoiceMessage()
|
||||
val recordingPlayer = coreContext.core.createLocalPlayer(
|
||||
playbackSoundCard,
|
||||
null,
|
||||
null
|
||||
)
|
||||
if (recordingPlayer != null) {
|
||||
player = recordingPlayer
|
||||
} else {
|
||||
Log.e("$TAG Failed to create a Player!")
|
||||
return
|
||||
}
|
||||
|
||||
player.addListener(playerListener)
|
||||
|
||||
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.state == Player.State.Closed) {
|
||||
player.open(recordingModel.filePath)
|
||||
player.seek(0)
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun togglePlayPause() {
|
||||
coreContext.postOnCoreThread {
|
||||
if (isPlaying.value == true) {
|
||||
pausePlayback()
|
||||
} else {
|
||||
startPlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun play() {
|
||||
coreContext.postOnCoreThread {
|
||||
startPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun pause() {
|
||||
coreContext.postOnCoreThread {
|
||||
pausePlayback()
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun startPlayback() {
|
||||
if (!::player.isInitialized) return
|
||||
|
||||
Log.i("$TAG Starting player")
|
||||
if (player.state == Player.State.Closed) {
|
||||
player.open(recordingModel.filePath)
|
||||
player.seek(0)
|
||||
}
|
||||
|
||||
Log.i("$TAG Acquiring audio focus")
|
||||
audioFocusRequest = AudioUtils.acquireAudioFocusForVoiceRecordingOrPlayback(
|
||||
coreContext.context
|
||||
)
|
||||
|
||||
player.start()
|
||||
isPlaying.postValue(true)
|
||||
|
||||
// We have to wait for the player to be started to have that information!
|
||||
val isVideoAvailable = player.isVideoAvailable
|
||||
Log.i(
|
||||
"$TAG Recording says video is ${if (isVideoAvailable) "available" else "not available"}"
|
||||
)
|
||||
isVideo.postValue(isVideoAvailable)
|
||||
|
||||
updatePositionJob = viewModelScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
for (tick in tickerChannel) {
|
||||
coreContext.postOnCoreThread {
|
||||
if (player.state == Player.State.Playing) {
|
||||
position.postValue(player.currentPosition)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun pausePlayback() {
|
||||
if (!::player.isInitialized) return
|
||||
|
||||
Log.i("$TAG Pausing player, releasing audio focus")
|
||||
if (audioFocusRequest != null) {
|
||||
AudioUtils.releaseAudioFocusForVoiceRecordingOrPlayback(
|
||||
coreContext.context,
|
||||
audioFocusRequest!!
|
||||
)
|
||||
}
|
||||
|
||||
player.pause()
|
||||
isPlaying.postValue(false)
|
||||
updatePositionJob?.cancel()
|
||||
updatePositionJob = null
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun stop() {
|
||||
if (!::player.isInitialized) return
|
||||
|
||||
Log.i("$TAG Stopping player")
|
||||
pause()
|
||||
position.postValue(0)
|
||||
player.seek(0)
|
||||
player.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -22,22 +22,11 @@ package org.linphone.ui.main.recordings.viewmodel
|
|||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media.AudioFocusRequestCompat
|
||||
import java.util.regex.Pattern
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.ticker
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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.ui.GenericViewModel
|
||||
import org.linphone.ui.main.recordings.model.RecordingModel
|
||||
import org.linphone.utils.AudioUtils
|
||||
import org.linphone.utils.Event
|
||||
import org.linphone.utils.FileUtils
|
||||
|
||||
|
|
@ -61,19 +50,6 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() {
|
|||
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)
|
||||
private var updatePositionJob: Job? = null
|
||||
|
||||
init {
|
||||
searchBarVisible.value = false
|
||||
fetchInProgress.value = true
|
||||
|
|
@ -83,17 +59,6 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
if (currentlyPlayedRecording != null) {
|
||||
stop(currentlyPlayedRecording)
|
||||
player?.removeListener(playerListener)
|
||||
player = null
|
||||
}
|
||||
recordings.value.orEmpty().forEach(RecordingModel::destroy)
|
||||
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@UiThread
|
||||
fun openSearchBar() {
|
||||
searchBarVisible.value = true
|
||||
|
|
@ -119,106 +84,8 @@ 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(currentlyPlayedRecording)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
updatePositionJob = 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)
|
||||
updatePositionJob?.cancel()
|
||||
updatePositionJob = null
|
||||
}
|
||||
|
||||
@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
|
||||
private fun computeList(filter: String) {
|
||||
recordings.value.orEmpty().forEach(RecordingModel::destroy)
|
||||
val list = arrayListOf<RecordingModel>()
|
||||
|
||||
for (file in FileUtils.getFileStorageDir(isRecording = true).listFiles().orEmpty()) {
|
||||
|
|
@ -226,16 +93,7 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() {
|
|||
val name = file.name
|
||||
|
||||
Log.d("$TAG Found file $path")
|
||||
val model = RecordingModel(
|
||||
path,
|
||||
name,
|
||||
{ model ->
|
||||
onRecordingStartedPlaying(model)
|
||||
},
|
||||
{ model ->
|
||||
onRecordingPaused(model)
|
||||
}
|
||||
)
|
||||
val model = RecordingModel(path, name)
|
||||
|
||||
if (filter.isEmpty() || model.sipUri.contains(filter)) {
|
||||
Log.i("$TAG Added file $path")
|
||||
|
|
@ -249,17 +107,7 @@ class RecordingsListViewModel @UiThread constructor() : GenericViewModel() {
|
|||
|
||||
if (LEGACY_RECORD_PATTERN.matcher(path).matches()) {
|
||||
Log.d("$TAG Found legacy file $path")
|
||||
val model = RecordingModel(
|
||||
path,
|
||||
name,
|
||||
{ model ->
|
||||
onRecordingStartedPlaying(model)
|
||||
},
|
||||
{ model ->
|
||||
onRecordingPaused(model)
|
||||
},
|
||||
true
|
||||
)
|
||||
val model = RecordingModel(path, name, true)
|
||||
|
||||
if (filter.isEmpty() || model.sipUri.contains(filter)) {
|
||||
Log.i("$TAG Added legacy file $path")
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import org.linphone.core.ChatRoom
|
|||
import org.linphone.core.ConferenceInfo
|
||||
import org.linphone.core.Friend
|
||||
import org.linphone.ui.main.chat.model.MessageModel
|
||||
import org.linphone.ui.main.recordings.model.RecordingModel
|
||||
import org.linphone.utils.Event
|
||||
|
||||
class SharedMainViewModel @UiThread constructor() : ViewModel() {
|
||||
|
|
@ -160,6 +161,10 @@ class SharedMainViewModel @UiThread constructor() : ViewModel() {
|
|||
MutableLiveData<Event<ArrayList<String>>>()
|
||||
}
|
||||
|
||||
/* Recordings related */
|
||||
|
||||
var playingRecording: RecordingModel? = null
|
||||
|
||||
/* Other */
|
||||
|
||||
val listOfSelectedSipUrisEvent: MutableLiveData<Event<ArrayList<String>>> by lazy {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
<variable
|
||||
name="model"
|
||||
type="org.linphone.ui.main.recordings.model.RecordingModel" />
|
||||
<variable
|
||||
name="onClickListener"
|
||||
type="View.OnClickListener" />
|
||||
<variable
|
||||
name="onLongClickListener"
|
||||
type="View.OnLongClickListener" />
|
||||
|
|
@ -21,6 +24,7 @@
|
|||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/cardview"
|
||||
android:onClick="@{onClickListener}"
|
||||
android:onLongClick="@{onLongClickListener}"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
|
|
@ -53,16 +57,16 @@
|
|||
android:drawableTint="?attr/color_main2_600"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/play_pause"/>
|
||||
app:layout_constraintEnd_toStartOf="@id/play"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/play_pause"
|
||||
android:onClick="@{() -> model.togglePlayPause()}"
|
||||
android:id="@+id/play"
|
||||
android:onClick="@{onClickListener}"
|
||||
android:layout_width="@dimen/big_icon_size"
|
||||
android:layout_height="@dimen/big_icon_size"
|
||||
android:layout_marginEnd="10dp"
|
||||
android:padding="12dp"
|
||||
android:src="@{model.isPlaying ? @drawable/pause_fill : @drawable/play_fill, default=@drawable/play_fill}"
|
||||
android:src="@drawable/play_fill"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:tint="?attr/color_main1_500" />
|
||||
|
|
@ -81,8 +85,9 @@
|
|||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/title"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
app:layout_constraintTop_toTopOf="@id/duration"
|
||||
app:layout_constraintBottom_toBottomOf="@id/duration"
|
||||
app:layout_constraintEnd_toStartOf="@id/duration" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
style="@style/default_text_style_300"
|
||||
|
|
@ -92,33 +97,16 @@
|
|||
android:layout_marginTop="3dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
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"/>
|
||||
app:layout_constraintTop_toBottomOf="@id/play"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="@id/play"
|
||||
app:layout_constraintEnd_toEndOf="@id/play" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
|
|
|
|||
195
app/src/main/res/layout/recording_player_fragment.xml
Normal file
195
app/src/main/res/layout/recording_player_fragment.xml
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
<?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">
|
||||
|
||||
<data>
|
||||
<import type="android.view.View" />
|
||||
<variable
|
||||
name="backClickListener"
|
||||
type="View.OnClickListener" />
|
||||
<variable
|
||||
name="shareClickListener"
|
||||
type="View.OnClickListener" />
|
||||
<variable
|
||||
name="exportClickListener"
|
||||
type="View.OnClickListener" />
|
||||
<variable
|
||||
name="viewModel"
|
||||
type="org.linphone.ui.main.recordings.viewmodel.RecordingMediaPlayerViewModel" />
|
||||
</data>
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/black">
|
||||
|
||||
<TextureView
|
||||
android:id="@+id/video_player"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@id/top_bar_barrier"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/audio_file_icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@drawable/music_notes"
|
||||
android:contentDescription="@null"
|
||||
android:visibility="@{viewModel.isVideo ? View.GONE : View.VISIBLE}"
|
||||
app:layout_constraintTop_toBottomOf="@id/top_bar_barrier"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:tint="@color/white" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/play_pause_audio_playback"
|
||||
android:onClick="@{() -> viewModel.togglePlayPause()}"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginBottom="@dimen/screen_bottom_margin"
|
||||
android:padding="8dp"
|
||||
android:contentDescription="@string/content_description_play_pause_audio_playback"
|
||||
android:src="@{viewModel.isPlaying ? @drawable/pause_fill : @drawable/play_fill, default=@drawable/play_fill}"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:tint="@color/white"/>
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progress"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:max="@{viewModel.recordingModel.duration, default=100}"
|
||||
android:progress="@{viewModel.position, default=75}"
|
||||
app:trackCornerRadius="5dp"
|
||||
app:trackThickness="10dp"
|
||||
app:trackColor="?attr/color_main1_100"
|
||||
app:indicatorColor="?attr/color_main1_500"
|
||||
app:layout_constraintTop_toTopOf="@id/play_pause_audio_playback"
|
||||
app:layout_constraintBottom_toBottomOf="@id/play_pause_audio_playback"
|
||||
app:layout_constraintStart_toEndOf="@id/play_pause_audio_playback"
|
||||
app:layout_constraintEnd_toStartOf="@id/duration"/>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
style="@style/default_text_style_700"
|
||||
android:id="@+id/duration"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@{viewModel.recordingModel.formattedDuration, default=`00:42`}"
|
||||
android:textSize="13sp"
|
||||
android:textColor="@color/white"
|
||||
app:layout_constraintTop_toTopOf="@id/play_pause_audio_playback"
|
||||
app:layout_constraintBottom_toBottomOf="@id/play_pause_audio_playback"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/top_bar_barrier"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:constraint_referenced_ids="back, date_time"
|
||||
app:barrierDirection="bottom" />
|
||||
|
||||
<View
|
||||
android:id="@+id/top_bar_background"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:background="@color/white"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="@id/top_bar_barrier"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/back"
|
||||
android:onClick="@{backClickListener}"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="@dimen/top_bar_height"
|
||||
android:adjustViewBounds="true"
|
||||
android:padding="15dp"
|
||||
android:src="@drawable/caret_left"
|
||||
android:contentDescription="@string/content_description_go_back_icon"
|
||||
app:tint="?attr/color_main1_500"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
style="@style/default_text_style_700"
|
||||
android:id="@+id/file_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{viewModel.recordingModel.displayName, default=`nomdufichier.jpg`}"
|
||||
android:textSize="13sp"
|
||||
android:textColor="?attr/color_main2_600"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
app:layout_constraintEnd_toStartOf="@id/share"
|
||||
app:layout_constraintStart_toEndOf="@id/back"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/date_time"/>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatTextView
|
||||
style="@style/default_text_style_300"
|
||||
android:id="@+id/date_time"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@{viewModel.recordingModel.dateTime, default=`envoyé le 02/05/2023 à 11h05`}"
|
||||
android:textSize="12sp"
|
||||
android:textColor="?attr/color_main2_500"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintTop_toBottomOf="@id/file_name"
|
||||
app:layout_constraintBottom_toBottomOf="@id/back"
|
||||
app:layout_constraintEnd_toStartOf="@id/share"
|
||||
app:layout_constraintStart_toEndOf="@id/back"/>
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/share"
|
||||
android:onClick="@{shareClickListener}"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="@dimen/top_bar_height"
|
||||
android:adjustViewBounds="true"
|
||||
android:padding="15dp"
|
||||
android:src="@drawable/share_network"
|
||||
android:contentDescription="@string/content_description_share_file"
|
||||
app:tint="?attr/color_main2_500"
|
||||
app:layout_constraintEnd_toStartOf="@id/save"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/save"
|
||||
android:onClick="@{exportClickListener}"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="@dimen/top_bar_height"
|
||||
android:adjustViewBounds="true"
|
||||
android:padding="15dp"
|
||||
android:src="@drawable/download_simple"
|
||||
android:contentDescription="@string/content_description_save_file"
|
||||
app:tint="?attr/color_main2_500"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/toasts_area"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginTop="@dimen/toast_top_margin"
|
||||
android:layout_marginStart="15dp"
|
||||
android:layout_marginEnd="15dp"
|
||||
app:layout_constraintWidth_max="@dimen/toast_max_width"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</layout>
|
||||
|
|
@ -176,7 +176,18 @@
|
|||
android:id="@+id/recordingsListFragment"
|
||||
android:name="org.linphone.ui.main.recordings.fragment.RecordingsListFragment"
|
||||
android:label="RecordingsListFragment"
|
||||
tools:layout="@layout/recordings_list_fragment" />
|
||||
tools:layout="@layout/recordings_list_fragment">
|
||||
|
||||
<action
|
||||
android:id="@+id/action_recordingsListFragment_to_recordingMediaPlayerFragment"
|
||||
app:destination="@id/recordingMediaPlayerFragment"
|
||||
app:enterAnim="@anim/slide_in_right"
|
||||
app:exitAnim="@anim/slide_out_left"
|
||||
app:popEnterAnim="@anim/slide_in_left"
|
||||
app:popExitAnim="@anim/slide_out_right"
|
||||
app:launchSingleTop="true" />
|
||||
|
||||
</fragment>
|
||||
|
||||
<action
|
||||
android:id="@+id/action_global_recordingsListFragment"
|
||||
|
|
@ -410,4 +421,10 @@
|
|||
app:enterAnim="@anim/slide_in"
|
||||
app:popExitAnim="@anim/slide_out" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/recordingMediaPlayerFragment"
|
||||
android:name="org.linphone.ui.main.recordings.fragment.RecordingMediaPlayerFragment"
|
||||
android:label="RecordingMediaPlayerFragment"
|
||||
tools:layout="@layout/recording_player_fragment"/>
|
||||
|
||||
</navigation>
|
||||
|
|
@ -8,7 +8,7 @@ ktlint = "11.3.1"
|
|||
|
||||
annotations = "1.8.0"
|
||||
activity = "1.9.0"
|
||||
appcompat = "1.7.0-rc01"
|
||||
appcompat = "1.7.0"
|
||||
constraintLayout = "2.1.4"
|
||||
coreKtx = "1.13.1"
|
||||
splashscreen = "1.2.0-alpha01"
|
||||
|
|
@ -16,7 +16,7 @@ telecom = "1.0.0-alpha03"
|
|||
media = "1.7.0"
|
||||
recyclerview = "1.3.2"
|
||||
slidingpanelayout = "1.2.0"
|
||||
window = "1.2.0"
|
||||
window = "1.3.0"
|
||||
gridlayout = "1.0.0"
|
||||
securityCryptoKtx = "1.1.0-alpha06"
|
||||
navigation = "2.7.7"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue