diff --git a/app/src/main/java/org/linphone/ui/main/recordings/adapter/RecordingsListAdapter.kt b/app/src/main/java/org/linphone/ui/main/recordings/adapter/RecordingsListAdapter.kt index e30343d7d..bd3d5a208 100644 --- a/app/src/main/java/org/linphone/ui/main/recordings/adapter/RecordingsListAdapter.kt +++ b/app/src/main/java/org/linphone/ui/main/recordings/adapter/RecordingsListAdapter.kt @@ -44,6 +44,10 @@ class RecordingsListAdapter : HeaderAdapter { var selectedAdapterPosition = -1 + val recordingClickedEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + val recordingLongClickedEvent: MutableLiveData> by lazy { MutableLiveData>() } @@ -74,6 +78,11 @@ class RecordingsListAdapter : binding.apply { lifecycleOwner = parent.findViewTreeLifecycleOwner() + setOnClickListener { + recordingClickedEvent.value = Event(model!!) + resetSelection() + } + setOnLongClickListener { selectedAdapterPosition = viewHolder.bindingAdapterPosition root.isSelected = true diff --git a/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingMediaPlayerFragment.kt b/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingMediaPlayerFragment.kt new file mode 100644 index 000000000..5ecefd920 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingMediaPlayerFragment.kt @@ -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 . + */ +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) + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingsListFragment.kt b/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingsListFragment.kt index 44119d693..436e60652 100644 --- a/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingsListFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/recordings/fragment/RecordingsListFragment.kt @@ -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( diff --git a/app/src/main/java/org/linphone/ui/main/recordings/model/RecordingModel.kt b/app/src/main/java/org/linphone/ui/main/recordings/model/RecordingModel.kt index 357071ea8..17d69cc45 100644 --- a/app/src/main/java/org/linphone/ui/main/recordings/model/RecordingModel.kt +++ b/app/src/main/java/org/linphone/ui/main/recordings/model/RecordingModel.kt @@ -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() - - val position = MutableLiveData() - 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 diff --git a/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingMediaPlayerViewModel.kt b/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingMediaPlayerViewModel.kt new file mode 100644 index 000000000..c4e2f66eb --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingMediaPlayerViewModel.kt @@ -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 . + */ +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() + + val isPlaying = MutableLiveData() + + val position = MutableLiveData() + + 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() + } +} diff --git a/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt index 4026624a3..688a25164 100644 --- a/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/recordings/viewmodel/RecordingsListViewModel.kt @@ -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>() } - 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() 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") diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt index a6467733f..1af2ee669 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt @@ -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>>() } + /* Recordings related */ + + var playingRecording: RecordingModel? = null + /* Other */ val listOfSelectedSipUrisEvent: MutableLiveData>> by lazy { diff --git a/app/src/main/res/layout/recording_list_cell.xml b/app/src/main/res/layout/recording_list_cell.xml index 436f5b8a9..0340bf0bf 100644 --- a/app/src/main/res/layout/recording_list_cell.xml +++ b/app/src/main/res/layout/recording_list_cell.xml @@ -8,6 +8,9 @@ + @@ -21,6 +24,7 @@ + app:layout_constraintEnd_toStartOf="@id/play"/> @@ -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" /> - - + app:layout_constraintTop_toBottomOf="@id/play" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="@id/play" + app:layout_constraintEnd_toEndOf="@id/play" /> diff --git a/app/src/main/res/layout/recording_player_fragment.xml b/app/src/main/res/layout/recording_player_fragment.xml new file mode 100644 index 000000000..706f2f80b --- /dev/null +++ b/app/src/main/res/layout/recording_player_fragment.xml @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml index 9131da9ed..82d636ce8 100644 --- a/app/src/main/res/navigation/main_nav_graph.xml +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -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"> + + + + + + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 060abd4c4..97a31e8c8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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"