diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt index 2eb35a86e5..41f086823b 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/IncomingVoiceMessageViewHolder.kt @@ -36,7 +36,9 @@ import com.nextcloud.talk.utils.preferences.AppPreferences import com.stfalcon.chatkit.messages.MessageHolders import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.ExecutionException @@ -61,9 +63,8 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : @Inject lateinit var dateUtils: DateUtils - @JvmField @Inject - var appPreferences: AppPreferences? = null + lateinit var appPreferences: AppPreferences lateinit var message: ChatMessage @@ -83,7 +84,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : sharedApplication!!.componentApplication.inject(this) val filename = message.selectedIndividualHashMap!!["name"] - val retrieved = appPreferences!!.getWaveFormFromFile(filename) + val retrieved = appPreferences.getWaveFormFromFile(filename) if (retrieved.isNotEmpty() && message.voiceMessageFloatArray == null || message.voiceMessageFloatArray?.isEmpty() == true @@ -103,7 +104,7 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : setParentMessageDataOnMessageItem(message) updateDownloadState(message) - binding.seekbar.max = message.voiceMessageDuration * ONE_SEC + binding.seekbar.max = 100 viewThemeUtils.talk.themeWaveFormSeekBar(binding.seekbar) viewThemeUtils.platform.colorCircularProgressBar(binding.progressBar, ColorRole.ON_SURFACE_VARIANT) @@ -139,10 +140,16 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : } }) - voiceMessageInterface.registerMessageToObservePlaybackSpeedPreferences(message.user.id) { speed -> - binding.playbackSpeedControlBtn.setSpeed(speed) + CoroutineScope(Dispatchers.Default).launch { + (voiceMessageInterface as ChatActivity).chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed -> + withContext(Dispatchers.Main) { + binding.playbackSpeedControlBtn.setSpeed(speed) + } + }.collect() } + binding.playbackSpeedControlBtn.setSpeed(appPreferences.getPreferredPlayback(message.actorId)) + Reaction().showReactions( message, ::clickOnReaction, @@ -158,9 +165,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : private fun showVoiceMessageDuration(message: ChatMessage) { if (message.voiceMessageDuration > 0) { - binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime( - message.voiceMessageDuration.toLong() - ) binding.voiceMessageDuration.visibility = View.VISIBLE } else { binding.voiceMessageDuration.visibility = View.INVISIBLE @@ -200,7 +204,6 @@ class IncomingVoiceMessageViewHolder(incomingView: View, payload: Any) : val t = message.voiceMessagePlayedSeconds.toLong() binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t) binding.voiceMessageDuration.visibility = View.VISIBLE - binding.seekbar.max = message.voiceMessageDuration * ONE_SEC binding.seekbar.progress = message.voiceMessageSeekbarProgress } else { showVoiceMessageDuration(message) diff --git a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt index 7e2d042417..5de55ed2fb 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/messages/OutcomingVoiceMessageViewHolder.kt @@ -38,7 +38,9 @@ import com.nextcloud.talk.utils.preferences.AppPreferences import com.stfalcon.chatkit.messages.MessageHolders import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.ExecutionException @@ -65,9 +67,8 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : @Inject lateinit var dateUtils: DateUtils - @JvmField @Inject - var appPreferences: AppPreferences? = null + lateinit var appPreferences: AppPreferences lateinit var message: ChatMessage @@ -90,7 +91,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : viewThemeUtils.platform.colorTextView(binding.messageTime, ColorRole.ON_SURFACE_VARIANT) val filename = message.selectedIndividualHashMap!!["name"] - val retrieved = appPreferences!!.getWaveFormFromFile(filename) + val retrieved = appPreferences.getWaveFormFromFile(filename) if (retrieved.isNotEmpty() && message.voiceMessageFloatArray == null || message.voiceMessageFloatArray?.isEmpty() == true @@ -99,6 +100,7 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : binding.seekbar.setWaveData(message.voiceMessageFloatArray!!) } + binding.seekbar.max = 100 binding.messageTime.text = dateUtils.getLocalTimeStringFromTimestamp(message.timestamp) colorizeMessageBubble(message) @@ -136,10 +138,16 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : setReadStatus(message.readStatus) - voiceMessageInterface.registerMessageToObservePlaybackSpeedPreferences(message.user.id) { speed -> - binding.playbackSpeedControlBtn.setSpeed(speed) + CoroutineScope(Dispatchers.Default).launch { + (voiceMessageInterface as ChatActivity).chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed -> + withContext(Dispatchers.Main) { + binding.playbackSpeedControlBtn.setSpeed(speed) + } + }.collect() } + binding.playbackSpeedControlBtn.setSpeed(appPreferences.getPreferredPlayback(message.actorId)) + Reaction().showReactions( message, ::clickOnReaction, @@ -199,9 +207,6 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : private fun showVoiceMessageDuration(message: ChatMessage) { if (message.voiceMessageDuration > 0) { - binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime( - message.voiceMessageDuration.toLong() - ) binding.voiceMessageDuration.visibility = View.VISIBLE } else { binding.voiceMessageDuration.visibility = View.INVISIBLE @@ -234,7 +239,6 @@ class OutcomingVoiceMessageViewHolder(outcomingView: View) : val t = message.voiceMessagePlayedSeconds.toLong() binding.voiceMessageDuration.text = android.text.format.DateUtils.formatElapsedTime(d - t) binding.voiceMessageDuration.visibility = View.VISIBLE - binding.seekbar.max = message.voiceMessageDuration * ONE_SEC binding.seekbar.progress = message.voiceMessageSeekbarProgress } else { showVoiceMessageDuration(message) diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 532f2e44f7..4cfb702e30 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -27,8 +27,6 @@ import android.database.Cursor import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable -import android.media.MediaMetadataRetriever -import android.media.MediaPlayer import android.net.Uri import android.os.Build import android.os.Bundle @@ -332,24 +330,12 @@ class ChatActivity : private val filesToUpload: MutableList = ArrayList() lateinit var sharedText: String - var mediaPlayer: MediaPlayer? = null - var mediaPlayerHandler: Handler? = null - - private var currentlyPlayedVoiceMessage: ChatMessage? = null - - // messy workaround for a mediaPlayer bug, don't delete - private var lastRecordMediaPosition: Int = 0 - private var lastRecordedSeeked: Boolean = false - lateinit var participantPermissions: ParticipantPermissions private var videoURI: Uri? = null private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (currentlyPlayedVoiceMessage != null) { - stopMediaPlayer(currentlyPlayedVoiceMessage!!) - } val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java) intent.putExtras(Bundle()) startActivity(intent) @@ -361,22 +347,6 @@ class ChatActivity : val typingParticipants = HashMap() var callStarted = false - private var voiceMessageToRestoreId = "" - private var voiceMessageToRestoreAudioPosition = 0 - private var voiceMessageToRestoreWasPlaying = false - - private val playbackSpeedPreferencesObserver: (Map) -> Unit = { speedPreferenceLiveData -> - mediaPlayer?.let { mediaPlayer -> - (mediaPlayer.isPlaying == true).also { - currentlyPlayedVoiceMessage?.let { message -> - mediaPlayer.playbackParams.let { params -> - params.setSpeed(chatViewModel.getPlaybackSpeedPreference(message).value) - mediaPlayer.playbackParams = params - } - } - } - } - } private val localParticipantMessageListener = object : SignalingMessageReceiver.LocalParticipantMessageListener { override fun onSwitchTo(token: String?) { @@ -451,35 +421,7 @@ class ChatActivity : onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - appPreferences.readVoiceMessagePlaybackSpeedPreferences().let { playbackSpeedPreferences -> - chatViewModel.applyPlaybackSpeedPreferences(playbackSpeedPreferences) - } - initObservers() - - if (savedInstanceState != null) { - // Restore value of members from saved state - var voiceMessageId = savedInstanceState.getString(CURRENT_AUDIO_MESSAGE_KEY, "") - var voiceMessagePosition = savedInstanceState.getInt(CURRENT_AUDIO_POSITION_KEY, 0) - var wasAudioPLaying = savedInstanceState.getBoolean(CURRENT_AUDIO_WAS_PLAYING_KEY, false) - if (!voiceMessageId.equals("")) { - Log.d(RESUME_AUDIO_TAG, "restored voice messageID: " + voiceMessageId) - Log.d(RESUME_AUDIO_TAG, "audio position: " + voiceMessagePosition) - Log.d(RESUME_AUDIO_TAG, "audio was playing: " + wasAudioPLaying.toString()) - voiceMessageToRestoreId = voiceMessageId - voiceMessageToRestoreAudioPosition = voiceMessagePosition - voiceMessageToRestoreWasPlaying = wasAudioPLaying - } else { - Log.d(RESUME_AUDIO_TAG, "stored voice message id is empty, not resuming audio playing") - voiceMessageToRestoreId = "" - voiceMessageToRestoreAudioPosition = 0 - voiceMessageToRestoreWasPlaying = false - } - } else { - voiceMessageToRestoreId = "" - voiceMessageToRestoreAudioPosition = 0 - voiceMessageToRestoreWasPlaying = false - } } private fun getMessageInputFragment(): MessageInputFragment { @@ -544,17 +486,6 @@ class ChatActivity : } override fun onSaveInstanceState(outState: Bundle) { - if (currentlyPlayedVoiceMessage != null) { - outState.putString(CURRENT_AUDIO_MESSAGE_KEY, currentlyPlayedVoiceMessage!!.id) - outState.putInt(CURRENT_AUDIO_POSITION_KEY, currentlyPlayedVoiceMessage!!.voiceMessagePlayedSeconds) - outState.putBoolean(CURRENT_AUDIO_WAS_PLAYING_KEY, currentlyPlayedVoiceMessage!!.isPlayingVoiceMessage) - Log.d(RESUME_AUDIO_TAG, "Stored current audio message ID: " + currentlyPlayedVoiceMessage!!.id) - Log.d( - RESUME_AUDIO_TAG, - "Audio Position: " + currentlyPlayedVoiceMessage!!.voiceMessagePlayedSeconds - .toString() + " | isPLaying: " + currentlyPlayedVoiceMessage!!.isPlayingVoiceMessage - ) - } chatViewModel.handleOrientationChange() super.onSaveInstanceState(outState) } @@ -931,6 +862,12 @@ class ChatActivity : }.collect() } + this.lifecycleScope.launch { + chatViewModel.mediaPlayerSeekbarObserver.onEach { msg -> + adapter?.update(msg) + }.collect() + } + chatViewModel.reactionDeletedViewState.observe(this) { state -> when (state) { is ChatViewModel.ReactionDeletedSuccessState -> { @@ -1169,8 +1106,6 @@ class ChatActivity : setupSwipeToReply() - chatViewModel.voiceMessagePlaybackSpeedPreferences.observe(this, playbackSpeedPreferencesObserver) - binding.unreadMessagesPopup.setOnClickListener { binding.messagesListView.smoothScrollToPosition(0) binding.unreadMessagesPopup.visibility = View.GONE @@ -1265,13 +1200,16 @@ class ChatActivity : val file = File(context.cacheDir, filename!!) if (file.exists()) { if (message.isPlayingVoiceMessage) { - pausePlayback(message) + chatViewModel.pauseMediaPlayer() + message.isPlayingVoiceMessage = false + adapter?.update(message) } else { val retrieved = appPreferences.getWaveFormFromFile(filename) if (retrieved.isEmpty()) { setUpWaveform(message) } else { - startPlayback(message) + Log.d("Julius", "UnPaused") + startPlayback(file, message) } } } else { @@ -1284,11 +1222,8 @@ class ChatActivity : adapter?.registerViewClickListener(R.id.playbackSpeedControlBtn) { button, message -> val nextSpeed = (button as PlaybackSpeedControl).getSpeed().next() - HashMap(appPreferences.readVoiceMessagePlaybackSpeedPreferences()).let { playbackSpeedPreferences -> - playbackSpeedPreferences[message.user.id] = nextSpeed - chatViewModel.applyPlaybackSpeedPreferences(playbackSpeedPreferences) - appPreferences.saveVoiceMessagePlaybackSpeedPreferences(playbackSpeedPreferences) - } + chatViewModel.setPlayBack(nextSpeed) + appPreferences.savePreferredPlayback(conversationUser!!.userId, nextSpeed) } } @@ -1303,14 +1238,37 @@ class ChatActivity : appPreferences.saveWaveFormForFile(filename, r.toTypedArray()) message.voiceMessageFloatArray = r withContext(Dispatchers.Main) { - startPlayback(message, thenPlay, backgroundPlayAllowed) + startPlayback(file, message) } } } else { - startPlayback(message, thenPlay, backgroundPlayAllowed) + startPlayback(file, message) } } + private fun startPlayback(file: File, message: ChatMessage) { + chatViewModel.clearMediaPlayerQueue() + chatViewModel.queueInMediaPlayer(file.canonicalPath, message) + chatViewModel.startCyclingMediaPlayer() + message.isPlayingVoiceMessage = true + adapter?.update(message) + + var pos = adapter?.getMessagePositionById(message.id)!! - 1 + do { + if (pos < 0) break + val nextItem = (adapter?.items?.get(pos)?.item) ?: break + val nextMessage = if (nextItem is ChatMessage) nextItem else break + if (!nextMessage.isVoiceMessage) break + + downloadFileToCache(nextMessage, false) { + val newFilename = nextMessage.selectedIndividualHashMap!!["name"] + val newFile = File(context.cacheDir, newFilename!!) + chatViewModel.queueInMediaPlayer(newFile.canonicalPath, nextMessage) + } + pos-- + } while (true && pos >= 0) + } + private fun initMessageHolders(): MessageHolders { val messageHolders = MessageHolders() val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!, viewThemeUtils) @@ -1690,253 +1648,20 @@ class ChatActivity : } } - @Suppress("Detekt.TooGenericExceptionCaught", "Detekt.LongMethod", "Detekt.NestedBlockDepth") - private fun startPlayback(message: ChatMessage, doPlay: Boolean = true, backgroundPlayAllowed: Boolean = false) { - if (!active && !backgroundPlayAllowed) { - // don't begin to play voice message if screen is not visible anymore. - // this situation might happen if file is downloading but user already left the chatview. - // If user returns to chatview, the old chatview instance is not attached anymore - // and he has to click the play button again (which is considered to be okay) - return - } - - initMediaPlayer(message) - - val id = message.id.toString() - val index = adapter?.getMessagePositionById(id) ?: 0 - - var nextMessage: ChatMessage? = null - for (i in VOICE_MESSAGE_CONTINUOUS_BEFORE..VOICE_MESSAGE_CONTINUOUS_AFTER) { - if (index - i < 0) { - break - } - if (i == 0 || index - i >= (adapter?.items?.size ?: 0)) { - continue - } - val curMsg = adapter?.items?.getOrNull(index - i)?.item - if (curMsg is ChatMessage) { - if (nextMessage == null && i > 0) { - nextMessage = curMsg - } - - if (curMsg.isVoiceMessage) { - if (curMsg.selectedIndividualHashMap == null) { - // WORKAROUND TO FETCH FILE INFO: - curMsg.getImageUrl() - } - val filename = curMsg.selectedIndividualHashMap!!["name"] - val file = File(context.cacheDir, filename!!) - if (!file.exists()) { - downloadFileToCache(curMsg, false) { - curMsg.isDownloadingVoiceMessage = false - curMsg.voiceMessageDuration = try { - val retriever = MediaMetadataRetriever() - retriever.setDataSource(file.absolutePath) // Set the audio file as the data source - val durationStr = - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - retriever.release() // Always release the retriever to free resources - (durationStr?.toIntOrNull() ?: 0) / ONE_SECOND_IN_MILLIS // Convert to int (seconds) - } catch (e: RuntimeException) { - Log.e( - TAG, - "An exception occurred while computing " + - "voice message duration for " + filename, - e - ) - 0 - } - adapter?.update(curMsg) - } - } else { - if (curMsg.voiceMessageDuration == 0) { - curMsg.voiceMessageDuration = try { - val retriever = MediaMetadataRetriever() - retriever.setDataSource(file.absolutePath) // Set the audio file as the data source - val durationStr = - retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) - retriever.release() // Always release the retriever to free resources - (durationStr?.toIntOrNull() ?: 0) / ONE_SECOND_IN_MILLIS // Convert to int (seconds) - } catch (e: RuntimeException) { - Log.e( - TAG, - "An exception occurred while computing " + - "voice message duration for " + filename, - e - ) - 0 - } - adapter?.update(curMsg) - } - } - } - } - } - - val hasConsecutiveVoiceMessage = if (nextMessage != null) nextMessage.isVoiceMessage else false - - mediaPlayer?.let { - if (!it.isPlaying && doPlay) { - chatViewModel.audioRequest(true) { - it.playbackParams = it.playbackParams.apply { - setSpeed(chatViewModel.getPlaybackSpeedPreference(message).value) - } - it.start() - } - } - - mediaPlayerHandler = Handler() - runOnUiThread(object : Runnable { - override fun run() { - if (mediaPlayer != null) { - if (message.isPlayingVoiceMessage) { - val pos = mediaPlayer!!.currentPosition.toFloat() / VOICE_MESSAGE_SEEKBAR_BASE - if (pos + VOICE_MESSAGE_PLAY_ADD_THRESHOLD < ( - mediaPlayer!!.duration.toFloat() / VOICE_MESSAGE_SEEKBAR_BASE - ) - ) { - lastRecordMediaPosition = mediaPlayer!!.currentPosition - message.voiceMessagePlayedSeconds = pos.toInt() - message.voiceMessageSeekbarProgress = mediaPlayer!!.currentPosition - if (mediaPlayer!!.currentPosition * VOICE_MESSAGE_MARK_PLAYED_FACTOR > - mediaPlayer!!.duration - ) { - // a voice message is marked as played when the mediaplayer position - // is at least at 5% of its duration - message.wasPlayedVoiceMessage = true - } - adapter?.update(message) - } else { - message.resetVoiceMessage = true - message.voiceMessagePlayedSeconds = 0 - message.voiceMessageSeekbarProgress = 0 - adapter?.update(message) - stopMediaPlayer(message) - if (hasConsecutiveVoiceMessage) { - val defaultMediaPlayer = MediaPlayer.create( - context, - R.raw - .next_voice_message_doodle - ) - defaultMediaPlayer.setOnCompletionListener { - defaultMediaPlayer.release() - setUpWaveform(nextMessage as ChatMessage, doPlay, true) - } - defaultMediaPlayer.start() - } - } - } - } - mediaPlayerHandler?.postDelayed(this, MILLISEC_15) - } - }) - - message.isDownloadingVoiceMessage = false - message.isPlayingVoiceMessage = doPlay - // message.voiceMessagePlayedSeconds = lastRecordMediaPosition / VOICE_MESSAGE_SEEKBAR_BASE - // message.voiceMessageSeekbarProgress = lastRecordMediaPosition - // the commented instructions objective was to update audio seekbarprogress - // in the case in which audio status is paused when the position is resumed - adapter?.update(message) - } - } - - private fun pausePlayback(message: ChatMessage) { - if (mediaPlayer!!.isPlaying) { - chatViewModel.audioRequest(false) { - mediaPlayer!!.pause() - } - } - - message.isPlayingVoiceMessage = false - adapter?.update(message) - } - - @Suppress("Detekt.TooGenericExceptionCaught") - private fun initMediaPlayer(message: ChatMessage) { - if (message != currentlyPlayedVoiceMessage) { - currentlyPlayedVoiceMessage?.let { stopMediaPlayer(it) } - } - - if (mediaPlayer == null) { - val fileName = message.selectedIndividualHashMap!!["name"] - val absolutePath = context.cacheDir.absolutePath + "/" + fileName - - try { - mediaPlayer = MediaPlayer().apply { - setDataSource(absolutePath) - prepare() - setOnPreparedListener { - currentlyPlayedVoiceMessage = message - message.voiceMessageDuration = mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE - lastRecordedSeeked = false - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - setOnMediaTimeDiscontinuityListener { mp, _ -> - if (lastRecordMediaPosition > ONE_SECOND_IN_MILLIS && !lastRecordedSeeked) { - mp.seekTo(lastRecordMediaPosition) - lastRecordedSeeked = true - } - } - // this ensures that audio can be resumed at a given position - this.seekTo(lastRecordMediaPosition) - } - setOnCompletionListener { - stopMediaPlayer(message) - } - } - } catch (e: Exception) { - Log.e(TAG, "failed to initialize mediaPlayer", e) - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - } - } - } - - private fun stopMediaPlayer(message: ChatMessage) { - message.isPlayingVoiceMessage = false - message.resetVoiceMessage = true - adapter?.update(message) - - currentlyPlayedVoiceMessage = null - lastRecordMediaPosition = 0 // this ensures that if audio track is changed, then it is played from the beginning - - mediaPlayerHandler?.removeCallbacksAndMessages(null) - - try { - mediaPlayer?.let { - if (it.isPlaying) { - Log.d(TAG, "media player is stopped") - chatViewModel.audioRequest(false) { - it.stop() - } - } - } - } catch (e: IllegalStateException) { - Log.e(TAG, "mediaPlayer was not initialized", e) - } finally { - mediaPlayer?.release() - mediaPlayer = null - } - } - - override fun updateMediaPlayerProgressBySlider(messageWithSlidedProgress: ChatMessage, progress: Int) { - if (mediaPlayer != null) { - if (messageWithSlidedProgress == currentlyPlayedVoiceMessage) { - mediaPlayer!!.seekTo(progress) - } - } + override fun updateMediaPlayerProgressBySlider(message: ChatMessage, progress: Int) { + chatViewModel.seekToMediaPlayer(progress) } override fun registerMessageToObservePlaybackSpeedPreferences( userId: String, listener: (speed: PlaybackSpeed) -> Unit ) { - chatViewModel.voiceMessagePlaybackSpeedPreferences.let { liveData -> - liveData.observe(this) { playbackSpeedPreferences -> - listener(playbackSpeedPreferences[userId] ?: PlaybackSpeed.NORMAL) - } - liveData.value?.let { playbackSpeedPreferences -> - listener(playbackSpeedPreferences[userId] ?: PlaybackSpeed.NORMAL) - } + CoroutineScope(Dispatchers.Default).launch { + chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed -> + withContext(Dispatchers.Main) { + listener(speed) + } + }.collect() } } @@ -2608,8 +2333,6 @@ class ChatActivity : if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() } - - chatViewModel.voiceMessagePlaybackSpeedPreferences.removeObserver(playbackSpeedPreferencesObserver) } private fun isActivityNotChangingConfigurations(): Boolean = !isChangingConfigurations @@ -2675,8 +2398,6 @@ class ChatActivity : actionBar?.setIcon(null) } - currentlyPlayedVoiceMessage?.let { stopMediaPlayer(it) } // FIXME, mediaplayer can sometimes be null here - adapter = null disposables.dispose() } @@ -2971,8 +2692,6 @@ class ChatActivity : adapter?.addToEnd(chatMessageList, false) } scrollToRequestedMessageIfNeeded() - // FENOM: add here audio resume policy - resumeAudioPlaybackIfNeeded() } private fun scrollToFirstUnreadMessage() { @@ -3035,37 +2754,6 @@ class ChatActivity : } } - /** - * this method must be called after that the adapter has finished loading ChatMessages items - * it searches by ID the message that was playing,s - * then, if it finds it, it restores audio position - * and eventually resumes audio playback - * @author Giacomo Pacini - */ - private fun resumeAudioPlaybackIfNeeded() { - if (voiceMessageToRestoreId != "") { - Log.d(RESUME_AUDIO_TAG, "begin method to resume audio playback") - - val pair = getItemFromAdapter(voiceMessageToRestoreId) - currentlyPlayedVoiceMessage = pair?.first - val voiceMessagePosition = pair?.second!! - - lastRecordMediaPosition = voiceMessageToRestoreAudioPosition * ONE_SECOND_IN_MILLIS - Log.d(RESUME_AUDIO_TAG, "trying to resume audio") - binding.messagesListView.scrollToPosition(voiceMessagePosition) - // WORKAROUND TO FETCH FILE INFO: - currentlyPlayedVoiceMessage!!.getImageUrl() - // see getImageUrl() source code - setUpWaveform(currentlyPlayedVoiceMessage!!, voiceMessageToRestoreWasPlaying) - Log.d(RESUME_AUDIO_TAG, "resume audio procedure completed") - } else { - Log.d(RESUME_AUDIO_TAG, "No voice message to restore") - } - voiceMessageToRestoreId = "" - voiceMessageToRestoreAudioPosition = 0 - voiceMessageToRestoreWasPlaying = false - } - private fun getItemFromAdapter(messageId: String): Pair? { if (adapter != null) { val messagePosition = adapter!!.items!!.indexOfFirst { diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt index a5dc768c68..945622eec2 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt @@ -16,6 +16,7 @@ import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import autodagger.AutoInjector import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.R @@ -24,6 +25,9 @@ import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedA import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.databinding.FragmentMessageInputVoiceRecordingBinding import com.nextcloud.talk.ui.theme.ViewThemeUtils +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject @AutoInjector(NextcloudTalkApplication::class) @@ -60,6 +64,7 @@ class MessageInputVoiceRecordingFragment : Fragment() { override fun onDestroyView() { super.onDestroyView() + chatActivity.messageInputViewModel.stopMediaPlayer() // if it wasn't stopped already this.lifecycle.removeObserver(chatActivity.messageInputViewModel) } @@ -68,13 +73,16 @@ class MessageInputVoiceRecordingFragment : Fragment() { chatActivity.messageInputViewModel.micInputAudioObserver.observe(viewLifecycleOwner) { binding.micInputCloud.setRotationSpeed(it.first, it.second) } - chatActivity.messageInputViewModel.mediaPlayerSeekbarObserver.observe(viewLifecycleOwner) { progress -> - if (progress >= SEEK_LIMIT) { - togglePausePlay() - binding.seekbar.progress = 0 - } else if (!pause) { - binding.seekbar.progress = progress - } + + lifecycleScope.launch { + chatActivity.messageInputViewModel.mediaPlayerSeekbarObserver.onEach { progress -> + if (progress >= SEEK_LIMIT) { + togglePausePlay() + binding.seekbar.progress = 0 + } else if (!pause && chatActivity.messageInputViewModel.isVoicePreviewPlaying.value == true) { + binding.seekbar.progress = progress + } + }.collect() } chatActivity.messageInputViewModel.getAudioFocusChange.observe(viewLifecycleOwner) { state -> @@ -107,7 +115,7 @@ class MessageInputVoiceRecordingFragment : Fragment() { binding.sendVoiceRecording.setOnClickListener { chatActivity.chatViewModel.stopAndSendAudioRecording( chatActivity.roomToken, - chatActivity.currentConversation!!.displayName!!, + chatActivity.currentConversation!!.displayName, MessageInputFragment.VOICE_MESSAGE_META_DATA ) clear() diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt index e610d2358f..4daae5b0fc 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt @@ -9,44 +9,119 @@ package com.nextcloud.talk.chat.data.io import android.media.MediaPlayer import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.ui.PlaybackSpeed +import com.nextcloud.talk.utils.preferences.AppPreferences +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileNotFoundException +import kotlin.math.ceil /** * Abstraction over the [MediaPlayer](https://developer.android.com/reference/android/media/MediaPlayer) class used * to manage the MediaPlayer instance. */ +@Suppress("TooManyFunctions") class MediaPlayerManager : LifecycleAwareManager { companion object { val TAG: String = MediaPlayerManager::class.java.simpleName - private const val SEEKBAR_UPDATE_DELAY = 15L - const val DIVIDER = 100f + private const val SEEKBAR_UPDATE_DELAY = 150L + private const val ONE_SEC = 1000 + private const val DIVIDER = 100f + private const val IS_PLAYED_CUTOFF = 5 + + @JvmStatic + private val manager: MediaPlayerManager = MediaPlayerManager() + + fun sharedInstance(preferences: AppPreferences): MediaPlayerManager = manager.apply { + appPreferences = preferences + } } + lateinit var appPreferences: AppPreferences + + enum class MediaPlayerManagerState { + DEFAULT, + SETUP, + STARTED, + STOPPED, + RESUMED, + PAUSED, + ERROR + } + + val backgroundPlayUIFlow: StateFlow + get() = _backgroundPlayUIFlow + private val _backgroundPlayUIFlow = MutableStateFlow(null) + + val managerState: Flow + get() = _managerState + private val _managerState = MutableStateFlow(MediaPlayerManagerState.DEFAULT) + + private val playQueue = mutableListOf>() + + val mediaPlayerSeekBarPositionMsg: Flow + get() = _mediaPlayerSeekBarPositionPair + private val _mediaPlayerSeekBarPositionPair: MutableSharedFlow = MutableSharedFlow() + + val mediaPlayerSeekBarPosition: Flow + get() = _mediaPlayerSeekBarPosition + private val _mediaPlayerSeekBarPosition: MutableSharedFlow = MutableSharedFlow() + private var mediaPlayer: MediaPlayer? = null - private var mediaPlayerPosition: Int = 0 private var loop = false private var scope = MainScope() + private var currentCycledMessage: ChatMessage? = null + private var currentDataSource: String = "" var mediaPlayerDuration: Int = 0 - private val _mediaPlayerSeekBarPosition: MutableLiveData = MutableLiveData() - val mediaPlayerSeekBarPosition: LiveData - get() = _mediaPlayerSeekBarPosition + var mediaPlayerPosition: Int = 0 /** * Starts playing audio from the given path, initializes or resumes if the player is already created. */ fun start(path: String) { + if (mediaPlayer != null && mediaPlayer!!.isPlaying) { + stop() + } + if (mediaPlayer == null || !scope.isActive) { init(path) } else { + _managerState.value = MediaPlayerManagerState.RESUMED + mediaPlayer!!.start() + loop = true + scope.launch { seekbarUpdateObserver() } + } + } + + /** + * Starting cycling through the playQueue, playing messages automatically unless stop() is called. + * + */ + fun startCycling() { + if (mediaPlayer != null && mediaPlayer!!.isPlaying) { + stop() + } + + val shouldReset = playQueue.first().first != currentDataSource + + if (mediaPlayer == null || !scope.isActive || shouldReset) { + initCycling() + } else { + _managerState.value = MediaPlayerManagerState.RESUMED mediaPlayer!!.start() loop = true scope.launch { seekbarUpdateObserver() } @@ -60,9 +135,13 @@ class MediaPlayerManager : LifecycleAwareManager { if (mediaPlayer != null) { Log.d(TAG, "media player destroyed") loop = false + scope.cancel() mediaPlayer!!.stop() mediaPlayer!!.release() mediaPlayer = null + currentCycledMessage = null + _backgroundPlayUIFlow.tryEmit(null) + _managerState.value = MediaPlayerManagerState.STOPPED } } @@ -72,7 +151,10 @@ class MediaPlayerManager : LifecycleAwareManager { fun pause() { if (mediaPlayer != null) { Log.d(TAG, "media player paused") + _managerState.value = MediaPlayerManagerState.PAUSED mediaPlayer!!.pause() + loop = false + _backgroundPlayUIFlow.tryEmit(null) } } @@ -89,14 +171,29 @@ class MediaPlayerManager : LifecycleAwareManager { private suspend fun seekbarUpdateObserver() { withContext(Dispatchers.IO) { + currentCycledMessage?.voiceMessageDuration = mediaPlayerDuration / ONE_SEC + currentCycledMessage?.resetVoiceMessage = false while (true) { if (!loop) { - return@withContext + // NOTE: ok so this doesn't stop the loop, but rather stop the update. Wasteful, but minimal + delay(SEEKBAR_UPDATE_DELAY) + continue } - if (mediaPlayer != null && mediaPlayer!!.isPlaying) { + + if (mediaPlayer != null && mediaPlayer?.isPlaying == true) { val pos = mediaPlayer!!.currentPosition + mediaPlayerPosition = pos val progress = (pos.toFloat() / mediaPlayerDuration) * DIVIDER - _mediaPlayerSeekBarPosition.postValue(progress.toInt()) + val progressI = ceil(progress).toInt() + val seconds = (pos / ONE_SEC) + _mediaPlayerSeekBarPosition.emit(progressI) + currentCycledMessage?.let { + it.isPlayingVoiceMessage = true + it.voiceMessageSeekbarProgress = progressI + it.voiceMessagePlayedSeconds = seconds + if (progressI >= IS_PLAYED_CUTOFF) it.wasPlayedVoiceMessage = true + _mediaPlayerSeekBarPositionPair.emit(it) + } } delay(SEEKBAR_UPDATE_DELAY) @@ -104,35 +201,140 @@ class MediaPlayerManager : LifecycleAwareManager { } } - @Suppress("Detekt.TooGenericExceptionCaught") + /** + * Adds a audio file to the play queue. for cycling through + * + * @throws FileNotFoundException if the file is not downloaded to cache first + */ + fun addToPlayList(path: String, chatMessage: ChatMessage) { + val file = File(path) + if (!file.exists()) { + throw FileNotFoundException("Cannot add to playlist without downloading to cache first for path\n$path") + } + + for (pair in playQueue) { + if (pair.first == path) return + } + + playQueue.add(Pair(path, chatMessage)) + } + + fun clearPlayList() { + playQueue.clear() + } + + /** + * Sets the player speed. + */ + fun setPlayBackSpeed(speed: PlaybackSpeed) { + if (mediaPlayer != null && mediaPlayer!!.isPlaying) { + mediaPlayer!!.playbackParams.let { params -> + params.setSpeed(speed.value) + mediaPlayer!!.playbackParams = params + } + } + } + private fun init(path: String) { try { mediaPlayer = MediaPlayer().apply { + _managerState.value = MediaPlayerManagerState.SETUP setDataSource(path) + currentDataSource = path prepareAsync() setOnPreparedListener { - mediaPlayerDuration = it.duration - start() - loop = true - scope = MainScope() - scope.launch { seekbarUpdateObserver() } + onPrepare() } } } catch (e: Exception) { Log.e(ChatActivity.TAG, "failed to initialize mediaPlayer", e) + _managerState.value = MediaPlayerManagerState.ERROR } } + private fun initCycling() { + try { + mediaPlayer = MediaPlayer().apply { + _managerState.value = MediaPlayerManagerState.SETUP + val pair = playQueue.iterator().next() + setDataSource(pair.first) + currentDataSource = pair.first + currentCycledMessage = pair.second + playQueue.removeAt(0) + prepareAsync() + setOnPreparedListener { + onPrepare() + } + + setOnCompletionListener { + if (playQueue.iterator().hasNext() && playQueue.first().first != currentDataSource) { + _managerState.value = MediaPlayerManagerState.SETUP + val nextPair = playQueue.iterator().next() + playQueue.removeAt(0) + mediaPlayer?.reset() + mediaPlayer?.setDataSource(nextPair.first) + currentCycledMessage = nextPair.second + prepare() + } else { + mediaPlayer?.release() + mediaPlayer = null + _backgroundPlayUIFlow.tryEmit(null) + currentCycledMessage?.let { + it.resetVoiceMessage = true + it.isPlayingVoiceMessage = false + } + runBlocking { + _mediaPlayerSeekBarPositionPair.emit(currentCycledMessage!!) + } + currentCycledMessage = null + loop = false + _managerState.value = MediaPlayerManagerState.STOPPED + } + } + } + } catch (e: Exception) { + Log.e(ChatActivity.TAG, "failed to initialize mediaPlayer", e) + _managerState.value = MediaPlayerManagerState.ERROR + } + } + + private fun MediaPlayer.onPrepare() { + mediaPlayerDuration = this.duration + + val playBackSpeed = if (currentCycledMessage?.actorId == null) { + PlaybackSpeed.NORMAL.value + } else { + appPreferences.getPreferredPlayback(currentCycledMessage?.actorId).value + } + mediaPlayer!!.playbackParams.setSpeed(playBackSpeed) + + start() + _managerState.value = MediaPlayerManagerState.STARTED + currentCycledMessage?.let { + it.isPlayingVoiceMessage = true + _backgroundPlayUIFlow.tryEmit(it) + } + loop = true + scope = MainScope() + scope.launch { seekbarUpdateObserver() } + } + override fun handleOnPause() { // unused atm } override fun handleOnResume() { - // unused atm + if (mediaPlayer != null && mediaPlayer!!.isPlaying) { + loop = true + } } override fun handleOnStop() { - stop() - scope.cancel() + loop = false + if (mediaPlayer != null && currentCycledMessage != null && mediaPlayer!!.isPlaying) { + CoroutineScope(Dispatchers.Default).launch { + _backgroundPlayUIFlow.tryEmit(currentCycledMessage!!) + } + } } } diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index a4a8ad5381..8c60c0b8ba 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -19,6 +19,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nextcloud.talk.chat.data.ChatMessageRepository import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager +import com.nextcloud.talk.chat.data.io.MediaPlayerManager import com.nextcloud.talk.chat.data.io.MediaRecorderManager import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.network.ChatNetworkDataSource @@ -41,11 +42,15 @@ import com.nextcloud.talk.ui.PlaybackSpeed import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew +import com.nextcloud.talk.utils.preferences.AppPreferences import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow @@ -57,6 +62,7 @@ import javax.inject.Inject @Suppress("TooManyFunctions", "LongParameterList") class ChatViewModel @Inject constructor( // should be removed here. Use it via RetrofitChatNetwork + private val appPreferences: AppPreferences, private val chatNetworkDataSource: ChatNetworkDataSource, private val chatRepository: ChatMessageRepository, private val conversationRepository: OfflineConversationsRepository, @@ -73,8 +79,11 @@ class ChatViewModel @Inject constructor( STOPPED } + private val mediaPlayerManager: MediaPlayerManager = MediaPlayerManager.sharedInstance(appPreferences) lateinit var currentLifeCycleFlag: LifeCycleFlag val disposableSet = mutableSetOf() + var mediaPlayerDuration = mediaPlayerManager.mediaPlayerDuration + val mediaPlayerPosition = mediaPlayerManager.mediaPlayerPosition fun getChatRepository(): ChatMessageRepository { return chatRepository @@ -85,6 +94,7 @@ class ChatViewModel @Inject constructor( currentLifeCycleFlag = LifeCycleFlag.RESUMED mediaRecorderManager.handleOnResume() chatRepository.handleOnResume() + mediaPlayerManager.handleOnResume() } override fun onPause(owner: LifecycleOwner) { @@ -94,6 +104,7 @@ class ChatViewModel @Inject constructor( disposableSet.clear() mediaRecorderManager.handleOnPause() chatRepository.handleOnPause() + mediaPlayerManager.handleOnPause() } override fun onStop(owner: LifecycleOwner) { @@ -101,8 +112,21 @@ class ChatViewModel @Inject constructor( currentLifeCycleFlag = LifeCycleFlag.STOPPED mediaRecorderManager.handleOnStop() chatRepository.handleOnStop() + mediaPlayerManager.handleOnStop() } + val backgroundPlayUIFlow = mediaPlayerManager.backgroundPlayUIFlow + + val mediaPlayerSeekbarObserver: Flow + get() = mediaPlayerManager.mediaPlayerSeekBarPositionMsg + + val managerStateFlow: Flow + get() = mediaPlayerManager.managerState + + val voiceMessagePlayBackUIFlow: Flow + get() = _voiceMessagePlayBackUIFlow + private val _voiceMessagePlayBackUIFlow: MutableSharedFlow = MutableSharedFlow() + val getAudioFocusChange: LiveData get() = audioFocusRequestManager.getManagerState @@ -122,10 +146,6 @@ class ChatViewModel @Inject constructor( val outOfOfficeViewState: LiveData get() = _outOfOfficeViewState - private val _voiceMessagePlaybackSpeedPreferences: MutableLiveData> = MutableLiveData() - val voiceMessagePlaybackSpeedPreferences: LiveData> - get() = _voiceMessagePlaybackSpeedPreferences - val getMessageFlow = chatRepository.messageFlow .onEach { _chatMessageViewState.value = if (_chatMessageViewState.value == ChatMessageInitialState) { @@ -661,12 +681,30 @@ class ChatViewModel @Inject constructor( emit(message.first()) } - fun applyPlaybackSpeedPreferences(speeds: Map) { - _voiceMessagePlaybackSpeedPreferences.postValue(speeds) + fun setPlayBack(speed: PlaybackSpeed) { + mediaPlayerManager.setPlayBackSpeed(speed) + CoroutineScope(Dispatchers.Default).launch { + _voiceMessagePlayBackUIFlow.emit(speed) + } + } + + fun startMediaPlayer(path: String) { + audioRequest(true) { + mediaPlayerManager.start(path) + } } - fun getPlaybackSpeedPreference(message: ChatMessage) = - _voiceMessagePlaybackSpeedPreferences.value?.get(message.user.id) ?: PlaybackSpeed.NORMAL + fun startCyclingMediaPlayer() = audioRequest(true, mediaPlayerManager::startCycling) + + fun pauseMediaPlayer() = audioRequest(false, mediaPlayerManager::pause) + + fun seekToMediaPlayer(progress: Int) = mediaPlayerManager.seekTo(progress) + + fun stopMediaPlayer() = audioRequest(false, mediaPlayerManager::stop) + + fun queueInMediaPlayer(path: String, msg: ChatMessage) = mediaPlayerManager.addToPlayList(path, msg) + + fun clearMediaPlayerQueue() = mediaPlayerManager.clearPlayList() inner class JoinRoomObserver : Observer { override fun onSubscribe(d: Disposable) { diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt index 02299d3097..2fa203f2ce 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt @@ -24,6 +24,7 @@ import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import com.nextcloud.talk.utils.message.SendMessageUtils import com.stfalcon.chatkit.commons.models.IMessage import io.reactivex.disposables.Disposable +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import javax.inject.Inject @@ -82,7 +83,7 @@ class MessageInputViewModel @Inject constructor( val micInputAudioObserver: LiveData> get() = audioRecorderManager.getAudioValues - val mediaPlayerSeekbarObserver: LiveData + val mediaPlayerSeekbarObserver: Flow get() = mediaPlayerManager.mediaPlayerSeekBarPosition private val _getEditChatMessage: MutableLiveData = MutableLiveData() diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index ae3e4242c4..272e7956e2 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -41,6 +41,7 @@ import androidx.activity.OnBackPressedCallback import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.MenuItemCompat import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment @@ -79,6 +80,7 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.chat.viewmodels.ChatViewModel import com.nextcloud.talk.contacts.ContactsActivityCompose import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel import com.nextcloud.talk.data.network.NetworkMonitor @@ -97,6 +99,7 @@ import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.settings.SettingsActivity +import com.nextcloud.talk.ui.BackgroundVoiceMessageCard import com.nextcloud.talk.ui.dialog.ChooseAccountDialogFragment import com.nextcloud.talk.ui.dialog.ChooseAccountShareToDialogFragment import com.nextcloud.talk.ui.dialog.ConversationsListBottomDialog @@ -138,12 +141,14 @@ import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import io.reactivex.subjects.BehaviorSubject import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.apache.commons.lang3.builder.CompareToBuilder import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import retrofit2.HttpException +import java.io.File import java.util.Objects import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -178,6 +183,9 @@ class ConversationsListActivity : @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var chatViewModel: ChatViewModel + lateinit var conversationsListViewModel: ConversationsListViewModel override val appBarLayoutType: AppBarLayoutType @@ -273,7 +281,7 @@ class ConversationsListActivity : if (adapter == null) { adapter = FlexibleAdapter(conversationItems, this, true) } else { - binding.loadingContent?.visibility = View.GONE + binding.loadingContent.visibility = View.GONE } adapter!!.addListener(this) prepareViews() @@ -387,6 +395,53 @@ class ConversationsListActivity : setConversationList(list) }.collect() } + + lifecycleScope.launch { + chatViewModel.backgroundPlayUIFlow.onEach { msg -> + binding.composeViewForBackgroundPlay.apply { + // Dispose of the Composition when the view's LifecycleOwner is destroyed + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + msg?.let { + val duration = chatViewModel.mediaPlayerDuration + val position = chatViewModel.mediaPlayerPosition + val offset = position.toFloat() / duration + val imageURI = ApiUtils.getUrlForAvatar( + currentUser?.baseUrl, + msg.actorId, + true + ) + val conversationImageURI = ApiUtils.getUrlForConversationAvatar( + ApiUtils.API_V1, + currentUser?.baseUrl, + msg.token + ) + + if (duration > 0) { + BackgroundVoiceMessageCard( + msg.actorDisplayName!!, + duration - position, + offset, + imageURI, + conversationImageURI + ) + .GetView({ isPaused -> + if (isPaused) { + chatViewModel.pauseMediaPlayer() + } else { + val filename = msg.selectedIndividualHashMap!!["name"] + val file = File(context.cacheDir, filename!!) + chatViewModel.startMediaPlayer(file.canonicalPath) + } + }) { + chatViewModel.stopMediaPlayer() + } + } + } + } + } + }.collect() + } } private fun setConversationList(list: List) { @@ -701,7 +756,7 @@ class ConversationsListActivity : if (!hasFilterEnabled()) filterableConversationItems = searchableConversationItems adapter!!.updateDataSet(filterableConversationItems, false) adapter!!.showAllHeaders() - binding.swipeRefreshLayoutView?.isEnabled = false + binding.swipeRefreshLayoutView.isEnabled = false searchBehaviorSubject.onNext(true) return true } @@ -715,9 +770,9 @@ class ConversationsListActivity : // cancel any pending searches searchHelper!!.cancelSearch() } - binding.swipeRefreshLayoutView?.isRefreshing = false + binding.swipeRefreshLayoutView.isRefreshing = false searchBehaviorSubject.onNext(false) - binding.swipeRefreshLayoutView?.isEnabled = true + binding.swipeRefreshLayoutView.isEnabled = true searchView!!.onActionViewCollapsed() binding.conversationListAppbar.stateListAnimator = AnimatorInflater.loadStateListAnimator( @@ -730,7 +785,7 @@ class ConversationsListActivity : viewThemeUtils.platform.resetStatusBar(this@ConversationsListActivity) } - val layoutManager = binding.recyclerView?.layoutManager as SmoothScrollLinearLayoutManager? + val layoutManager = binding.recyclerView.layoutManager as SmoothScrollLinearLayoutManager? layoutManager?.scrollToPositionWithOffset(0, 0) return true } @@ -823,18 +878,18 @@ class ConversationsListActivity : private fun initOverallLayout(isConversationListNotEmpty: Boolean) { if (isConversationListNotEmpty) { - if (binding.emptyLayout?.visibility != View.GONE) { - binding.emptyLayout?.visibility = View.GONE + if (binding.emptyLayout.visibility != View.GONE) { + binding.emptyLayout.visibility = View.GONE } - if (binding.swipeRefreshLayoutView?.visibility != View.VISIBLE) { - binding.swipeRefreshLayoutView?.visibility = View.VISIBLE + if (binding.swipeRefreshLayoutView.visibility != View.VISIBLE) { + binding.swipeRefreshLayoutView.visibility = View.VISIBLE } } else { - if (binding.emptyLayout?.visibility != View.VISIBLE) { - binding.emptyLayout?.visibility = View.VISIBLE + if (binding.emptyLayout.visibility != View.VISIBLE) { + binding.emptyLayout.visibility = View.VISIBLE } - if (binding.swipeRefreshLayoutView?.visibility != View.GONE) { - binding.swipeRefreshLayoutView?.visibility = View.GONE + if (binding.swipeRefreshLayoutView.visibility != View.GONE) { + binding.swipeRefreshLayoutView.visibility = View.GONE } } } @@ -1014,24 +1069,24 @@ class ConversationsListActivity : } } }) - binding.recyclerView?.setOnTouchListener { v: View, _: MotionEvent? -> + binding.recyclerView.setOnTouchListener { v: View, _: MotionEvent? -> if (!isDestroyed) { val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(v.windowToken, 0) } false } - binding.swipeRefreshLayoutView?.setOnRefreshListener { + binding.swipeRefreshLayoutView.setOnRefreshListener { fetchRooms() fetchPendingInvitations() } - binding.swipeRefreshLayoutView?.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) } - binding.emptyLayout?.setOnClickListener { showNewConversationsScreen() } - binding.floatingActionButton?.setOnClickListener { + binding.swipeRefreshLayoutView.let { viewThemeUtils.androidx.themeSwipeRefreshLayout(it) } + binding.emptyLayout.setOnClickListener { showNewConversationsScreen() } + binding.floatingActionButton.setOnClickListener { run(context) showNewConversationsScreen() } - binding.floatingActionButton?.let { viewThemeUtils.material.themeFAB(it) } + binding.floatingActionButton.let { viewThemeUtils.material.themeFAB(it) } binding.switchAccountButton.setOnClickListener { if (resources != null && resources!!.getBoolean(R.bool.multiaccount_support)) { @@ -1206,7 +1261,7 @@ class ConversationsListActivity : @SuppressLint("CheckResult") // handled by helper private fun startMessageSearch(search: String?) { - binding.swipeRefreshLayoutView?.isRefreshing = true + binding.swipeRefreshLayoutView.isRefreshing = true searchHelper?.startMessageSearch(search!!) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) @@ -1451,8 +1506,8 @@ class ConversationsListActivity : filesToShare?.forEach { UploadAndShareFilesWorker.upload( it, - selectedConversation!!.token!!, - selectedConversation!!.displayName!!, + selectedConversation!!.token, + selectedConversation!!.displayName, null ) } @@ -1925,15 +1980,15 @@ class ConversationsListActivity : } // add unified search result at the end of the list adapter!!.addItems(adapter!!.mainItemCount + adapter!!.scrollableHeaders.size, adapterItems) - binding.recyclerView?.scrollToPosition(0) + binding.recyclerView.scrollToPosition(0) } } - binding.swipeRefreshLayoutView?.isRefreshing = false + binding.swipeRefreshLayoutView.isRefreshing = false } private fun onMessageSearchError(throwable: Throwable) { handleHttpExceptions(throwable) - binding.swipeRefreshLayoutView?.isRefreshing = false + binding.swipeRefreshLayoutView.isRefreshing = false showErrorDialog() } diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt index 3ce6cdf19f..c98b4ba8c2 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt @@ -12,6 +12,7 @@ import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.chat.data.io.AudioRecorderManager import com.nextcloud.talk.chat.data.io.MediaPlayerManager import com.nextcloud.talk.chat.data.io.MediaRecorderManager +import com.nextcloud.talk.utils.preferences.AppPreferences import dagger.Module import dagger.Provides @@ -29,8 +30,10 @@ class ManagerModule { } @Provides - fun provideMediaPlayerManager(): MediaPlayerManager { - return MediaPlayerManager() + fun provideMediaPlayerManager(preferences: AppPreferences): MediaPlayerManager { + return MediaPlayerManager().apply { + appPreferences = preferences + } } @Provides diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index e4c7600508..823cab6a51 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -10,8 +10,8 @@ package com.nextcloud.talk.dagger.modules import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.nextcloud.talk.chat.viewmodels.ChatViewModel -import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel +import com.nextcloud.talk.contacts.ContactsViewModel import com.nextcloud.talk.conversation.viewmodel.ConversationViewModel import com.nextcloud.talk.conversationcreation.ConversationCreationViewModel import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel @@ -125,8 +125,6 @@ abstract class ViewModelModule { @ViewModelKey(MessageInputViewModel::class) abstract fun messageInputViewModel(viewModel: MessageInputViewModel): ViewModel - // TODO I had a merge conflict here that went weird. choose their version - @Binds @IntoMap @ViewModelKey(ConversationInfoViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/ui/BackgroundVoiceMessageCard.kt b/app/src/main/java/com/nextcloud/talk/ui/BackgroundVoiceMessageCard.kt new file mode 100644 index 0000000000..d46f850903 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/BackgroundVoiceMessageCard.kt @@ -0,0 +1,198 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Your Name + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.ui + +import android.animation.ValueAnimator +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import autodagger.AutoInjector +import coil.compose.AsyncImage +import com.nextcloud.talk.R +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.contacts.loadImage +import com.nextcloud.talk.ui.theme.ViewThemeUtils +import javax.inject.Inject + +@AutoInjector(NextcloudTalkApplication::class) +class BackgroundVoiceMessageCard( + val name: String, + val duration: Int, + val offset: Float, + val imageURI: String, + val conversationImageURI: String +) { + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var context: Context + + private val progressState = mutableFloatStateOf(0.01f) + private val animator = ValueAnimator.ofFloat(offset, 1.0f) + + init { + NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + animator.duration = duration.toLong() + animator.addUpdateListener { animation -> + progressState.floatValue = animation.animatedValue as Float + } + + animator.start() + } + + @Composable + fun GetView(onPlayPaused: (isPaused: Boolean) -> Unit, onClosed: () -> Unit) { + MaterialTheme(colorScheme = viewThemeUtils.getColorScheme(context)) { + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier + .padding(16.dp, 0.dp) + ) { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(8.dp)) + .fillMaxWidth(progressState.floatValue) + .height(4.dp) + ) + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.CenterVertically) + ) { + var isPausedIcon by remember { mutableStateOf(false) } + + IconButton( + onClick = { + isPausedIcon = !isPausedIcon + onPlayPaused(isPausedIcon) + if (isPausedIcon) { + animator.pause() + } else { + animator.resume() + } + } + ) { + Icon( + imageVector = if (isPausedIcon) { + Icons.Filled.PlayArrow + } else { + ImageVector.vectorResource(R.drawable.ic_baseline_pause_voice_message_24) + }, + contentDescription = "contentDescription", + modifier = Modifier + .size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + Spacer(modifier = Modifier.size(16.dp)) + + Box( + modifier = Modifier + .weight(.8f) + .align(Alignment.CenterVertically), + contentAlignment = Alignment.Center + ) { + Row { + Box { + val errorPlaceholderImage: Int = R.drawable.account_circle_96dp + val loadedImage = loadImage(imageURI, context, errorPlaceholderImage) + val conversationImage = loadImage( + conversationImageURI, + context, + errorPlaceholderImage + ) + AsyncImage( + model = conversationImage, + contentDescription = stringResource(R.string.user_avatar), + modifier = Modifier + .size(width = 45.dp, height = 45.dp) + .padding(8.dp) + .offset(10.dp, 10.dp) + ) + + AsyncImage( + model = loadedImage, + contentDescription = stringResource(R.string.user_avatar), + modifier = Modifier + .size(width = 45.dp, height = 45.dp) + .padding(8.dp) + ) + } + + Text( + name, + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(8.dp), + color = MaterialTheme.colorScheme.onBackground + ) + } + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.CenterVertically) + ) { + IconButton( + onClick = { + onClosed() + } + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "contentDescription", + modifier = Modifier + .size(24.dp) + .padding(2.dp), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java index beb152a69c..60699b78cd 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java @@ -11,12 +11,8 @@ import android.annotation.SuppressLint; -import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel; import com.nextcloud.talk.ui.PlaybackSpeed; -import java.util.List; -import java.util.Map; - @SuppressLint("NonConstantResourceId") public interface AppPreferences { @@ -175,9 +171,11 @@ public interface AppPreferences { int getLastKnownId(String internalConversationId, int defaultValue); - void saveVoiceMessagePlaybackSpeedPreferences(Map speeds); + void deleteAllMessageQueuesFor(String userId); + + void savePreferredPlayback(String userId, PlaybackSpeed speed); - Map readVoiceMessagePlaybackSpeedPreferences(); + PlaybackSpeed getPreferredPlayback(String userId); Long getNotificationWarningLastPostponedDate(); diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt index d0867b6f84..ec92560cfe 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt @@ -8,7 +8,6 @@ package com.nextcloud.talk.utils.preferences import android.content.Context -import android.util.Log import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey @@ -24,9 +23,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking -import kotlinx.serialization.SerializationException -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json @ExperimentalCoroutinesApi @Suppress("TooManyFunctions", "DeferredResultUnused", "EmptyFunctionBlock") @@ -500,26 +496,42 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { return if (lastReadId.isNotEmpty()) lastReadId.toInt() else defaultValue } - override fun saveVoiceMessagePlaybackSpeedPreferences(speeds: Map) { - Json.encodeToString(speeds).let { - runBlocking { async { writeString(VOICE_MESSAGE_PLAYBACK_SPEEDS, it) } } + override fun deleteAllMessageQueuesFor(userId: String) { + runBlocking { + async { + val keyList = mutableListOf>() + val preferencesMap = context.dataStore.data.first().asMap() + for (preference in preferencesMap) { + if (preference.key.name.contains("$userId@")) { + keyList.add(preference.key) + } + } + + for (key in keyList) { + context.dataStore.edit { + it.remove(key) + } + } + } } } - override fun readVoiceMessagePlaybackSpeedPreferences(): Map { - return runBlocking { - async { readString(VOICE_MESSAGE_PLAYBACK_SPEEDS, "{}").first() } - }.getCompleted().let { - try { - Json.decodeFromString>(it) - .map { entry -> entry.key to PlaybackSpeed.byName(entry.value) }.toMap() - } catch (e: SerializationException) { - Log.e(TAG, "ignoring invalid json format in voice message playback speed preferences", e) - emptyMap() + override fun savePreferredPlayback(userId: String, speed: PlaybackSpeed) { + runBlocking { + async { + writeString(userId + PLAY_BACK, speed.name) } } } + override fun getPreferredPlayback(userId: String): PlaybackSpeed = + runBlocking { + async { + val name = readString(userId + PLAY_BACK).first() + return@async if (name == "") PlaybackSpeed.NORMAL else PlaybackSpeed.byName(name) + } + }.getCompleted() + override fun getNotificationWarningLastPostponedDate(): Long = runBlocking { async { readLong(LAST_NOTIFICATION_WARNING).first() } @@ -609,6 +621,8 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { const val DB_ROOM_MIGRATED = "db_room_migrated" const val PHONE_BOOK_INTEGRATION_LAST_RUN = "phone_book_integration_last_run" const val TYPING_STATUS = "typing_status" + const val MESSAGE_QUEUE = "@message_queue" + const val PLAY_BACK = "_playback" const val VOICE_MESSAGE_PLAYBACK_SPEEDS = "voice_message_playback_speeds" const val SHOW_REGULAR_NOTIFICATION_WARNING = "show_regular_notification_warning" const val LAST_NOTIFICATION_WARNING = "last_notification_warning" diff --git a/app/src/main/res/layout/activity_conversations.xml b/app/src/main/res/layout/activity_conversations.xml index 5a30ddb162..25084501c9 100644 --- a/app/src/main/res/layout/activity_conversations.xml +++ b/app/src/main/res/layout/activity_conversations.xml @@ -143,6 +143,11 @@ app:popupTheme="@style/appActionBarPopupMenu" app:titleTextColor="@color/fontAppbar" tools:title="@string/nc_app_product_name" /> + +