From ccb55978276208185c8fbccf5323c94f994789ad Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Tue, 23 Jun 2026 10:52:04 -0500 Subject: [PATCH 1/2] Migrating to ExoPlayer Changing source of truth of voice messages to be MediaSessionService Added a bunch of code to handle updates to the player, listen to state changes, and update the UI Fixed waveforms, they are now drawn in bounds removing dead code from ChatViewModel.kt Signed-off-by: rapterjet2004 --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 10 + .../com/nextcloud/talk/chat/ChatActivity.kt | 318 +++++++++++++----- .../talk/chat/data/io/MediaPlayerManager.kt | 171 +++++----- .../chat/data/io/VoiceMessageMediaService.kt | 34 ++ .../talk/chat/viewmodels/ChatViewModel.kt | 99 +----- .../talk/ui/ComposeWaveformSeekbar.kt | 29 +- .../nextcloud/talk/ui/chat/ChatMessageView.kt | 2 - .../com/nextcloud/talk/ui/chat/ChatView.kt | 2 - .../nextcloud/talk/ui/chat/VoiceMessage.kt | 12 +- 10 files changed, 398 insertions(+), 280 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/data/io/VoiceMessageMediaService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ba7ff5b1045..d72aad3e7d0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -194,6 +194,7 @@ configurations.configureEach { } dependencies { + implementation("androidx.media3:media3-session:1.10.1") kapt("org.jetbrains.kotlin:kotlin-metadata-jvm:$kotlinVersion") implementation("androidx.room:room-testing-android:$roomVersion") implementation("androidx.compose.foundation:foundation-layout:1.11.4") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 79f71e43b21..54182da11ba 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,6 +47,7 @@ + @@ -334,6 +335,15 @@ android:resource="@xml/contacts" /> + + + + + + 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 2b622b7a804..92c8f6a6e71 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -18,12 +18,14 @@ import android.Manifest import android.annotation.SuppressLint import android.content.ClipData import android.content.ClipboardManager +import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.res.AssetFileDescriptor import android.database.Cursor import android.location.LocationManager +import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.os.Bundle @@ -68,6 +70,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalDensity +import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.PermissionChecker import androidx.core.content.PermissionChecker.PERMISSION_GRANTED @@ -81,6 +84,11 @@ import androidx.fragment.app.commit import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken import androidx.recyclerview.widget.LinearLayoutManager import androidx.work.Data import androidx.work.OneTimeWorkRequest @@ -89,6 +97,7 @@ import androidx.work.WorkManager import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import com.google.common.util.concurrent.ListenableFuture import com.nextcloud.android.common.ui.color.ColorUtil import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R @@ -98,6 +107,7 @@ import com.nextcloud.talk.adapters.messages.CallStartedMessageInterface import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.chat.data.io.VoiceMessageMediaService import com.nextcloud.talk.attachmentpreview.FileAttachmentPreviewFragment import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.FileParameters @@ -224,6 +234,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe @@ -296,6 +307,9 @@ class ChatActivity : mutableStateOf(ChatViewModel.UpcomingEventUIState.None) private val overflowContainerHeightPx = mutableIntStateOf(0) + private var mediaControllerFuture: ListenableFuture? = null + private var mediaController: MediaController? = null + private val startSelectContactForResult = registerForActivityResult( ActivityResultContracts .StartActivityForResult() @@ -398,6 +412,73 @@ class ChatActivity : private lateinit var pickMultipleMedia: ActivityResultLauncher + private var progressJob: Job? = null + + private fun updateSeekbarUi() { + mediaController?.let { controller -> + val currentPosition = controller.currentPosition + val duration = controller.duration.takeIf { it > 0 } ?: 1 + + val progress = (currentPosition.toFloat() / duration) * FLOAT_100 + val secondsPlayed = (currentPosition / MILLIS_1000) + + val msg = chatViewModel.currentVoiceMessage?.apply { + voiceMessageSeekbarProgress = kotlin.math.ceil(progress).toInt() + voiceMessagePlayedSeconds = secondsPlayed.toInt() + } + + val currentMediaId = controller.currentMediaItem?.mediaId + if (currentMediaId != null && msg != null) { + chatViewModel.syncVoiceMessageUiState(msg) + } + } + } + + private fun startProgressPolling() { + progressJob?.cancel() + progressJob = lifecycleScope.launch { + while (isActive) { + updateSeekbarUi() + // Poll every 150ms for smooth seekbar updates + delay(MILLIS_150) + } + } + } + + private fun stopProgressPolling() { + progressJob?.cancel() + progressJob = null + } + + private val playerListener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) { + startProgressPolling() + } else { + stopProgressPolling() + updateSeekbarUi() + } + + chatViewModel.currentVoiceMessage?.apply { + isPlayingVoiceMessage = isPlaying + }?.let { chatViewModel.syncVoiceMessageUiState(it) } + } + + // Catches instant changes (e.g., manual seeks, skipping to the next track) + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + updateSeekbarUi() + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + // Ensures the UI resets immediately when transitioning to the next voice message + updateSeekbarUi() + } + } + private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { if (chatViewModel.chatMode.value == ChatViewModel.ChatMode.SEARCH_MODE) { @@ -797,7 +878,7 @@ class ChatActivity : ) { val currentlyPlayingId by chatViewModel.currentlyPlayedMessageId.collectAsState(null) - val isOneToOneConversation = uiState.isOneToOneConversation + val isOneToOneConversation by remember { mutableStateOf(uiState.isOneToOneConversation) } Log.d(TAG, "isOneToOneConversation=" + isOneToOneConversation) // list of the file ids of messages being downloaded @@ -819,7 +900,6 @@ class ChatActivity : state = ChatViewState( chatItems = uiState.items, isOneToOneConversation = isOneToOneConversation, - currentlyPlayingVoiceMessageId = currentlyPlayingId, conversationThreadId = conversationThreadId, chatMode = chatMode, highlightedMessageId = uiState.highlightedMessageId, @@ -839,8 +919,15 @@ class ChatActivity : onSwipeReply = { handleSwipeToReply(it) }, onFileClick = { downloadAndOpenFile(it, openWhenDownloadState, downloadingFileState) }, onPollClick = { pollId, pollName -> openPollDialog(pollId, pollName) }, - onVoicePlayPauseClick = { onVoicePlayPauseClickCompose(it) }, - onVoiceSeek = { _, progress -> chatViewModel.seekToMediaPlayer(progress) }, + onVoicePlayPauseClick = { onVoiceClick(it) }, + onVoiceSeek = { id, progress -> + mediaController?.let { controller -> + if (id.toString() == controller.currentMediaItem?.mediaId) { + val pos = controller.duration * progress / 100f + controller.seekTo(pos.toLong()) + } + } + }, onVoiceSpeedClick = { onVoiceSpeedClickCompose(it) }, onReactionClick = { messageId, emoji -> handleReactionClick(messageId, emoji) }, onReactionLongClick = { messageId -> openReactionsDialog(messageId) }, @@ -983,6 +1070,122 @@ class ChatActivity : } } + @Suppress("ReturnCount", "CyclomaticComplexMethod") + private fun onVoiceClick(messageId: Int) { + fun getAudioDuration(audioFilePath: String): Long { + val retriever = MediaMetadataRetriever() + + return try { + retriever.setDataSource(audioFilePath) + val durationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + val durationLong = durationString?.toLong() ?: 0L + + durationLong / ONE_SECOND_IN_MILLIS + } catch (e: IllegalArgumentException) { + e.printStackTrace() + 0L + } finally { + retriever.release() + } + } + + fun setupAndPlay(controller: MediaController, message: ChatMessage, file: String) { + val avatarUrl = chatViewModel.getAvatarUrl(message) + + val metadata = MediaMetadata.Builder() + .setTitle(message.actorDisplayName) + .setArtist("Voice Message") + .setArtworkUri(avatarUrl.toUri()) + .build() + + val mediaItem = MediaItem.Builder() + .setMediaId(message.jsonMessageId.toString()) + .setMediaMetadata(metadata) + .setUri(file) + .build() + + controller.setMediaItem(mediaItem) + + controller.prepare() + controller.play() + } + + fun setUpWaveform(message: ChatMessage, file: File) { + if (message.voiceMessageFloatArray != null) return + + val filename = message.fileParameters.name + message.isDownloadingVoiceMessage = true + + chatViewModel.syncVoiceMessageUiState(message) + + CoroutineScope(Dispatchers.Default).launch { + val waveform = AudioUtils.audioFileToFloatArray(file) + appPreferences.saveWaveFormForFile(filename, waveform.toTypedArray()) + message.voiceMessageFloatArray = waveform + + withContext(Dispatchers.Main) { + message.isDownloadingVoiceMessage = false + chatViewModel.syncVoiceMessageUiState(message) + } + } + } + + fun prepareVoiceMessage(controller: MediaController, message: ChatMessage): Boolean { + val currentMessageId = message.jsonMessageId.toString() + val filename = message.fileParameters.name + if (filename.isEmpty()) { + return true + } + + val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, filename) ?: return true + val fileURI = file.toUri() + val filePath = fileURI.toString() + + chatViewModel.syncVoiceMessageUiState( + message.apply { + voiceMessageDuration = getAudioDuration(filePath).toInt() + } + ) + + if (controller.currentMediaItem?.mediaId != currentMessageId) { + if (!file.exists()) { + downloadFileToCache(message, true) { + setupAndPlay(controller, message, filePath) + setUpWaveform(message, file) + } + } else { + setupAndPlay(controller, message, filePath) + setUpWaveform(message, file) + } + + return true + } + return false + } + + lifecycleScope.launch { + val message = chatViewModel.getMessageById(messageId.toLong()).first() + + mediaController?.let { controller -> + // If a message is still playing, it must be paused first before another can be loaded + val currentMessageId = message.jsonMessageId.toString() + if (controller.isPlaying && controller.currentMediaItem?.mediaId != currentMessageId) return@launch + + chatViewModel.currentVoiceMessage = message + + // If the controller is playing a new voice message, initialize + if (prepareVoiceMessage(controller, message)) return@launch + + // If the controller is playing the same voice message, pause/resume + if (controller.isPlaying) { + controller.pause() + } else { + controller.play() + } + } + } + } + @Composable private fun LazyListState.visibleItemsWithThreshold(): List = remember(this) { @@ -1020,57 +1223,6 @@ class ChatActivity : chatViewModel.jumpToQuotedMessage(messageId.toLong()) } - private fun onVoicePlayPauseClickCompose(messageId: Int) { - lifecycleScope.launch { - val isCurrentlyPlaying = chatViewModel.uiState.value.items - .mapNotNull { (it as? ChatViewModel.ChatItem.MessageItem)?.uiMessage } - .firstOrNull { it.id == messageId } - ?.content - ?.let { it as? MessageTypeContent.Voice } - ?.isPlaying ?: false - - val message = chatViewModel.getMessageById(messageId.toLong()).first() - val filename = message.fileParameters.name - if (filename.isEmpty()) { - return@launch - } - - val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, filename) - if (file == null) { - return@launch - } - if (file.exists()) { - if (isCurrentlyPlaying) { - chatViewModel.pauseMediaPlayer(true) - chatViewModel.pauseVoiceMessageUiState(messageId) - } else { - val uiSpeed = chatViewModel.uiState.value.items - .mapNotNull { (it as? ChatViewModel.ChatItem.MessageItem)?.uiMessage } - .firstOrNull { it.id == messageId } - ?.content - ?.let { it as? MessageTypeContent.Voice } - ?.playbackSpeed ?: PlaybackSpeed.NORMAL - chatViewModel.setPlayBack(uiSpeed) - - val retrieved = appPreferences.getWaveFormFromFile(filename) - if (retrieved.isEmpty()) { - setUpWaveform(message) - } else { - if (message.voiceMessageFloatArray == null || message.voiceMessageFloatArray!!.isEmpty()) { - message.voiceMessageFloatArray = retrieved.toFloatArray() - chatViewModel.syncVoiceMessageUiState(message) - } - startPlayback(file, message) - } - } - } else { - downloadFileToCache(message, true) { - setUpWaveform(message) - } - } - } - } - @Suppress("Detekt.TooGenericExceptionCaught") private fun startDirectChat(actorId: String) { lifecycleScope.launch { @@ -1216,6 +1368,19 @@ class ChatActivity : active = true this.lifecycle.addObserver(AudioUtils) this.lifecycle.addObserver(chatViewModel) + + val sessionToken = SessionToken(this, ComponentName(this, VoiceMessageMediaService::class.java)) + mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + + mediaControllerFuture?.addListener({ + mediaController = mediaControllerFuture?.get() + + mediaController?.addListener(playerListener) + + if (mediaController?.isPlaying == true) { + startProgressPolling() + } + }, ContextCompat.getMainExecutor(this)) } override fun onSaveInstanceState(outState: Bundle) { @@ -1228,6 +1393,12 @@ class ChatActivity : active = false this.lifecycle.removeObserver(AudioUtils) this.lifecycle.removeObserver(chatViewModel) + + mediaController?.removeListener(playerListener) + stopProgressPolling() + + mediaControllerFuture?.let { MediaController.releaseFuture(it) } + mediaController = null } @OptIn(FlowPreview::class) @@ -1873,38 +2044,6 @@ class ChatActivity : } } - private fun setUpWaveform(message: ChatMessage, thenPlay: Boolean = true, backgroundPlayAllowed: Boolean = false) { - val filename = message.fileParameters.name - val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, filename) - if (file == null) { - return - } - if (file.exists() && message.voiceMessageFloatArray == null) { - message.isDownloadingVoiceMessage = true - chatViewModel.syncVoiceMessageUiState(message) - CoroutineScope(Dispatchers.Default).launch { - val r = AudioUtils.audioFileToFloatArray(file) - appPreferences.saveWaveFormForFile(filename, r.toTypedArray()) - message.voiceMessageFloatArray = r - withContext(Dispatchers.Main) { - message.isDownloadingVoiceMessage = false - chatViewModel.syncVoiceMessageUiState(message) - startPlayback(file, message) - } - } - } else { - startPlayback(file, message) - } - } - - private fun startPlayback(file: File, message: ChatMessage) { - chatViewModel.clearMediaPlayerQueue() - chatViewModel.queueInMediaPlayer(file.canonicalPath, message) - chatViewModel.startCyclingMediaPlayer() - message.isPlayingVoiceMessage = true - chatViewModel.syncVoiceMessageUiState(message) - } - private fun updateTypingIndicator() { val names = typingParticipants.values.map { it.name } runOnUiThread { typingParticipantNames = names } @@ -2820,7 +2959,7 @@ class ChatActivity : ) } - public override fun onDestroy() { + override fun onDestroy() { super.onDestroy() logConversationInfos("onDestroy") @@ -4054,6 +4193,9 @@ class ChatActivity : private const val GET_ROOM_INFO_DELAY_NORMAL: Long = 30000 private const val GET_ROOM_INFO_DELAY_LOBBY: Long = 5000 private const val MILLIS_250 = 250L + private const val MILLIS_150 = 150L + private const val MILLIS_1000 = 1000L + private const val FLOAT_100 = 100f private const val AGE_THRESHOLD_FOR_DELETE_MESSAGE: Int = 21600000 // (6 hours in millis = 6 * 3600 * 1000) private const val REQUEST_SHARE_FILE_PERMISSION: Int = 221 private const val REQUEST_RECORD_AUDIO_PERMISSION = 222 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 c7084560977..1916c85c2f3 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 @@ -6,9 +6,12 @@ */ package com.nextcloud.talk.chat.data.io - -import android.media.MediaPlayer import android.util.Log +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.ui.PlaybackSpeed @@ -28,6 +31,7 @@ import kotlinx.coroutines.withContext import java.io.File import java.io.FileNotFoundException import kotlin.math.ceil +import kotlin.time.Duration.Companion.milliseconds /** * Abstraction over the [MediaPlayer](https://developer.android.com/reference/android/media/MediaPlayer) class used @@ -81,7 +85,7 @@ class MediaPlayerManager : LifecycleAwareManager { get() = _mediaPlayerSeekBarPosition private val _mediaPlayerSeekBarPosition: MutableSharedFlow = MutableSharedFlow() - private var mediaPlayer: MediaPlayer? = null + private var mediaPlayer: ExoPlayer? = null private var loop = false private var scope = MainScope() @@ -106,7 +110,7 @@ class MediaPlayerManager : LifecycleAwareManager { init(path) } else { _managerState.value = MediaPlayerManagerState.RESUMED - mediaPlayer!!.start() + mediaPlayer!!.play() loop = true scope.launch { seekbarUpdateObserver() } } @@ -121,13 +125,13 @@ class MediaPlayerManager : LifecycleAwareManager { stop() } - val shouldReset = playQueue.first().first != currentDataSource + val shouldReset = playQueue.isNotEmpty() && playQueue.first().first != currentDataSource if (mediaPlayer == null || !scope.isActive || shouldReset) { initCycling() } else { _managerState.value = MediaPlayerManagerState.RESUMED - mediaPlayer!!.start() + mediaPlayer!!.play() loop = true scope.launch { seekbarUpdateObserver() } } @@ -171,46 +175,48 @@ class MediaPlayerManager : LifecycleAwareManager { fun seekTo(progress: Int) { if (mediaPlayer != null) { val pos = mediaPlayer!!.duration * (progress / DIVIDER) - mediaPlayer!!.seekTo(pos.toInt()) + mediaPlayer!!.seekTo(pos.toLong()) mediaPlayerPosition = pos.toInt() } } private suspend fun seekbarUpdateObserver() { + _currentCycledMessage.value?.voiceMessageDuration = mediaPlayerDuration / ONE_SEC + _currentCycledMessage.value?.resetVoiceMessage = false withContext(Dispatchers.IO) { - _currentCycledMessage.value?.voiceMessageDuration = mediaPlayerDuration / ONE_SEC - _currentCycledMessage.value?.resetVoiceMessage = false while (true) { if (!loop) { // NOTE: ok so this doesn't stop the loop, but rather stop the update. Wasteful, but minimal - delay(SEEKBAR_UPDATE_DELAY) + delay(SEEKBAR_UPDATE_DELAY.milliseconds) continue } - mediaPlayer?.let { player -> - try { - if (!player.isPlaying) return@let - } catch (e: IllegalStateException) { - Log.e(TAG, "Seekbar updated during an improper state: $e") - return@let - } + withContext(Dispatchers.Main) { + mediaPlayer?.let { p -> + try { + if (!p.isPlaying) return@let + } catch (e: IllegalStateException) { + Log.e(TAG, "Seekbar updated during an improper state: $e") + return@let + } - val pos = player.currentPosition - mediaPlayerPosition = pos - val progress = (pos.toFloat() / mediaPlayerDuration) * DIVIDER - val progressI = ceil(progress).toInt() - val seconds = (pos / ONE_SEC) - _mediaPlayerSeekBarPosition.emit(progressI) - _currentCycledMessage.value?.let { msg -> - msg.isPlayingVoiceMessage = true - msg.voiceMessageSeekbarProgress = progressI - msg.voiceMessagePlayedSeconds = seconds - if (progressI >= IS_PLAYED_CUTOFF) msg.wasPlayedVoiceMessage = true - _mediaPlayerSeekBarPositionMsg.emit(msg) + val pos = p.currentPosition + mediaPlayerPosition = pos.toInt() + val progress = (pos.toFloat() / mediaPlayerDuration) * DIVIDER + val progressI = ceil(progress).toInt() + val seconds = (pos / ONE_SEC).toInt() + _mediaPlayerSeekBarPosition.emit(progressI) + _currentCycledMessage.value?.let { msg -> + msg.isPlayingVoiceMessage = true + msg.voiceMessageSeekbarProgress = progressI + msg.voiceMessagePlayedSeconds = seconds + if (progressI >= IS_PLAYED_CUTOFF) msg.wasPlayedVoiceMessage = true + _mediaPlayerSeekBarPositionMsg.emit(msg) + } } } - delay(SEEKBAR_UPDATE_DELAY) + delay(SEEKBAR_UPDATE_DELAY.milliseconds) } } } @@ -243,23 +249,27 @@ class MediaPlayerManager : LifecycleAwareManager { fun setPlayBackSpeed(speed: PlaybackSpeed) { requestedPlaybackSpeed = speed if (mediaPlayer != null && mediaPlayer!!.isPlaying) { - mediaPlayer!!.playbackParams.let { params -> - params.speed = speed.value - mediaPlayer!!.playbackParams = params - } + mediaPlayer!!.playbackParameters = PlaybackParameters(speed.value) } } private fun init(path: String) { try { - mediaPlayer = MediaPlayer().apply { + val context = NextcloudTalkApplication.sharedApplication!!.applicationContext + mediaPlayer = ExoPlayer.Builder(context).build().apply { _managerState.value = MediaPlayerManagerState.SETUP - setDataSource(path) + setMediaItem(MediaItem.fromUri(path)) currentDataSource = path - prepareAsync() - setOnPreparedListener { - onPrepare() - } + prepare() + addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY && + _managerState.value == MediaPlayerManagerState.SETUP + ) { + onPrepare() + } + } + }) } } catch (e: Exception) { Log.e(ChatActivity.TAG, "failed to initialize mediaPlayer", e) @@ -269,48 +279,53 @@ class MediaPlayerManager : LifecycleAwareManager { private fun initCycling() { try { - mediaPlayer = MediaPlayer().apply { + val context = NextcloudTalkApplication.sharedApplication!!.applicationContext + mediaPlayer = ExoPlayer.Builder(context).build().apply { _managerState.value = MediaPlayerManagerState.SETUP val pair = playQueue.iterator().next() - setDataSource(pair.first) + setMediaItem(MediaItem.fromUri(pair.first)) currentDataSource = pair.first _currentCycledMessage.value = 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.value = nextPair.second - prepare() - } else { - mediaPlayer?.release() - mediaPlayer = null - _backgroundPlayUIFlow.tryEmit(null) - _currentCycledMessage.value?.let { - it.resetVoiceMessage = true - it.isPlayingVoiceMessage = false - it.voiceMessageSeekbarProgress = 0 - it.voiceMessagePlayedSeconds = 0 - } - val completedMessage = _currentCycledMessage.value - _currentCycledMessage.value = null - if (completedMessage != null) { - scope.launch { - _mediaPlayerSeekBarPositionMsg.emit(completedMessage) + prepare() + addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY && + _managerState.value == MediaPlayerManagerState.SETUP + ) { + onPrepare() + } else if (playbackState == Player.STATE_ENDED) { + if (playQueue.iterator().hasNext() && playQueue.first().first != currentDataSource) { + _managerState.value = MediaPlayerManagerState.SETUP + val nextPair = playQueue.iterator().next() + playQueue.removeAt(0) + mediaPlayer?.setMediaItem(MediaItem.fromUri(nextPair.first)) + currentDataSource = nextPair.first + _currentCycledMessage.value = nextPair.second + prepare() + } else { + mediaPlayer?.release() + mediaPlayer = null + _backgroundPlayUIFlow.tryEmit(null) + _currentCycledMessage.value?.let { + it.resetVoiceMessage = true + it.isPlayingVoiceMessage = false + it.voiceMessageSeekbarProgress = 0 + it.voiceMessagePlayedSeconds = 0 + } + val completedMessage = _currentCycledMessage.value + _currentCycledMessage.value = null + if (completedMessage != null) { + scope.launch { + _mediaPlayerSeekBarPositionMsg.emit(completedMessage) + } + } + loop = false + _managerState.value = MediaPlayerManagerState.STOPPED } } - loop = false - _managerState.value = MediaPlayerManagerState.STOPPED } - } + }) } } catch (e: Exception) { Log.e(ChatActivity.TAG, "failed to initialize mediaPlayer", e) @@ -318,8 +333,8 @@ class MediaPlayerManager : LifecycleAwareManager { } } - private fun MediaPlayer.onPrepare() { - mediaPlayerDuration = this.duration + private fun ExoPlayer.onPrepare() { + mediaPlayerDuration = this.duration.toInt() val playBackSpeed = requestedPlaybackSpeed?.value ?: if (_currentCycledMessage.value?.actorId == null) { @@ -327,9 +342,9 @@ class MediaPlayerManager : LifecycleAwareManager { } else { appPreferences.getPreferredPlayback(_currentCycledMessage.value?.actorId).value } - mediaPlayer!!.playbackParams = mediaPlayer!!.playbackParams.setSpeed(playBackSpeed) + playbackParameters = PlaybackParameters(playBackSpeed) - start() + play() _managerState.value = MediaPlayerManagerState.STARTED _currentCycledMessage.value?.let { it.isPlayingVoiceMessage = true diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/VoiceMessageMediaService.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/VoiceMessageMediaService.kt new file mode 100644 index 00000000000..3deed37744b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/VoiceMessageMediaService.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.io + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService + +class VoiceMessageMediaService : MediaSessionService() { + private var mediaSession: MediaSession? = null + + @OptIn(UnstableApi::class) + override fun onCreate() { + super.onCreate() + val player = ExoPlayer.Builder(this).build() + mediaSession = MediaSession.Builder(this, player).build() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = mediaSession + + override fun onDestroy() { + mediaSession?.player?.release() + mediaSession?.release() + mediaSession = null + super.onDestroy() + } +} 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 d0dc70fc1cb..9956571b83b 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 @@ -35,6 +35,7 @@ import com.nextcloud.talk.conversationlist.DirectShareHelper import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository import com.nextcloud.talk.conversationlist.data.network.OfflineFirstConversationsRepository import com.nextcloud.talk.conversationlist.viewmodels.ConversationsListViewModel.Companion.FOLLOWED_THREADS_EXIST +import com.nextcloud.talk.dagger.modules.ApplicationScope import com.nextcloud.talk.data.database.mappers.toDomainModel import com.nextcloud.talk.data.database.model.ChatMessageEntity import com.nextcloud.talk.data.user.model.User @@ -80,7 +81,6 @@ import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers -import com.nextcloud.talk.dagger.modules.ApplicationScope import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -206,8 +206,6 @@ class ChatViewModel @AssistedInject constructor( private val mediaPlayerManager: MediaPlayerManager = MediaPlayerManager.sharedInstance(appPreferences) lateinit var currentLifeCycleFlag: LifeCycleFlag val disposableSet = mutableSetOf() - var mediaPlayerDuration = mediaPlayerManager.mediaPlayerDuration - val mediaPlayerPosition = mediaPlayerManager.mediaPlayerPosition var messageDraft: MessageDraft = MessageDraft() var hiddenUpcomingEvent: String? = null @@ -260,25 +258,9 @@ class ChatViewModel @AssistedInject constructor( showUnreadMessagesMarker = shouldShow } - val backgroundPlayUIFlow = mediaPlayerManager.backgroundPlayUIFlow - val mediaPlayerSeekbarObserver: Flow get() = mediaPlayerManager.mediaPlayerSeekBarPositionMsg - // FIXME - map this to string id or some other kinda of id idk - val currentlyPlayedMessageId: Flow - get() = mediaPlayerManager.currentCycledMessage.map { msg -> msg?.jsonMessageId } - - val managerStateFlow: Flow - get() = mediaPlayerManager.managerState - - val voiceMessagePlayBackUIFlow: Flow - get() = _voiceMessagePlayBackUIFlow - private val _voiceMessagePlayBackUIFlow: MutableSharedFlow = MutableSharedFlow() - - val getAudioFocusChange: LiveData - get() = audioFocusRequestManager.getManagerState - private val _recordTouchObserver: MutableLiveData = MutableLiveData() val recordTouchObserver: LiveData get() = _recordTouchObserver @@ -860,7 +842,10 @@ class ChatViewModel @AssistedInject constructor( } } + var currentVoiceMessage: ChatMessage? = null + fun syncVoiceMessageUiState(message: ChatMessage) { + currentVoiceMessage = message _uiState.update { current -> val updatedItems = current.items.map { item -> if (item is ChatItem.MessageItem && item.uiMessage.id == message.jsonMessageId) { @@ -2060,31 +2045,10 @@ class ChatViewModel @AssistedInject constructor( _getVoiceRecordingLocked.postValue(boolean) } - // Made this so that the MediaPlayer in ChatActivity can be focused. Eventually the player logic should be moved - // to the MediaPlayerManager class, so the audio focus logic can be handled in ChatViewModel, as it's done in - // the MessageInputViewModel - fun audioRequest(request: Boolean, callback: () -> Unit) { - audioFocusRequestManager.audioFocusRequest(request, callback) - } - fun handleOrientationChange() { _getCapabilitiesViewState.value = GetCapabilitiesStartState } - // fun getMessageById(url: String, conversationModel: ConversationModel, messageId: Long): Flow = - // flow { - // val bundle = Bundle() - // bundle.putString(BundleKeys.KEY_CHAT_URL, url) - // bundle.putString( - // BundleKeys.KEY_CREDENTIALS, - // currentUser.getCredentials() - // ) - // bundle.putString(BundleKeys.KEY_ROOM_TOKEN, conversationModel.token) - // - // val message = chatRepository.getMessage(messageId, bundle) - // emit(message.first()) - // } - @Deprecated("use getMessageById(messageId: Long)") fun getMessageById(url: String, conversationModel: ConversationModel, messageId: Long): Flow { val bundle = Bundle().apply { @@ -2112,61 +2076,6 @@ class ChatViewModel @AssistedInject constructor( return chatRepository.getMessage(messageId, bundle) } - // fun getIndividualMessageFromServer( - // credentials: String, - // baseUrl: String, - // token: String, - // messageId: String - // ): Flow = - // flow { - // val messages = chatNetworkDataSource.getContextForChatMessage( - // credentials = credentials, - // baseUrl = baseUrl, - // token = token, - // messageId = messageId, - // limit = 1, - // threadId = null - // ) - // - // if (messages.isNotEmpty()) { - // val message = messages[0] - // emit(message.toDomainModel()) - // } else { - // emit(null) - // } - // }.flowOn(Dispatchers.IO) - - suspend fun getNumberOfThreadReplies(threadId: Long): Int = chatRepository.getNumberOfThreadReplies(threadId) - - fun setPlayBack(speed: PlaybackSpeed) { - mediaPlayerManager.setPlayBackSpeed(speed) - viewModelScope.launch { - _voiceMessagePlayBackUIFlow.emit(speed) - } - } - - fun startMediaPlayer(path: String) { - audioRequest(true) { - mediaPlayerManager.start(path) - } - } - - fun startCyclingMediaPlayer() = audioRequest(true, mediaPlayerManager::startCycling) - - fun pauseMediaPlayer(notifyUI: Boolean) { - audioRequest(false) { - mediaPlayerManager.pause(notifyUI) - } - } - - 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) { disposableSet.add(d) diff --git a/app/src/main/java/com/nextcloud/talk/ui/ComposeWaveformSeekbar.kt b/app/src/main/java/com/nextcloud/talk/ui/ComposeWaveformSeekbar.kt index 4ea119ad7fd..2765aee7b68 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/ComposeWaveformSeekbar.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/ComposeWaveformSeekbar.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ExperimentalMaterial3Api @@ -30,10 +31,17 @@ const val WAVEFORM_THUMB_SIZE = 20 const val WAVEFORM_SIZE = 30 const val MAX_HEIGHT = 100 const val OVERLAP = 0.025 +const val PROGRESS = 0.97f @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ComposeWaveformSeekBar(value: Float, onValueChange: (Float) -> Unit, modifier: Modifier, waveData: FloatArray) { +fun ComposeWaveformSeekBar( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier, + waveData: FloatArray, + enabled: Boolean +) { val barWidth = Stroke.DefaultMiter val thumbSize = WAVEFORM_THUMB_SIZE.dp val inversePrimary = MaterialTheme.colorScheme.inversePrimary @@ -41,6 +49,7 @@ fun ComposeWaveformSeekBar(value: Float, onValueChange: (Float) -> Unit, modifie Slider( value = value, + enabled = enabled, onValueChange = onValueChange, track = { Box( @@ -48,14 +57,18 @@ fun ComposeWaveformSeekBar(value: Float, onValueChange: (Float) -> Unit, modifie .drawWithCache { onDrawBehind { val height = this.size.height - val width = this.size.width + val width = this.size.width + 8.dp.value val midpoint = (this.size.height / 2f) val barGap = (width - waveData.size * barWidth) / (waveData.size - 1).toFloat() + 1 for (i in waveData.indices) { val x: Float = i * (barWidth + barGap) - val y: Float = waveData[i] * height - val isXBeforeThumb = (x / this.size.width) <= value + + if (x < 0f || x > size.width) continue + + val y: Float = (waveData[i] * height).coerceIn(0f, midpoint) + + val isXBeforeThumb = x <= value * width drawLine( if (isXBeforeThumb) inversePrimary else onPrimaryContainer, @@ -86,11 +99,13 @@ fun Preview() { val waveData = remember { FloatArray(WAVEFORM_SIZE) { (Math.random() % 1).toFloat() } } ComposeWaveformSeekBar( - 0.0f, + PROGRESS, {}, modifier = Modifier .height(MAX_HEIGHT.dp) - .fillMaxWidth(), - waveData + .fillMaxWidth() + .padding(8.dp), + waveData, + true ) } diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt index f439969e9d6..3672ca8a8b2 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatMessageView.kt @@ -52,7 +52,6 @@ private const val QUOTE_HIGHLIGHT_HOLD_MILLIS = 700L private const val QUOTE_HIGHLIGHT_FADE_OUT_MILLIS = 1500 data class ChatMessageContext( - val currentlyPlayingVoiceMessageId: Int? = null, val isOneToOneConversation: Boolean = false, val conversationThreadId: Long? = null, val hasChatPermission: Boolean = true, @@ -182,7 +181,6 @@ fun ChatMessageView( message = message, isOneToOneConversation = context.isOneToOneConversation, conversationThreadId = context.conversationThreadId, - currentlyPlayingVoiceMessageId = context.currentlyPlayingVoiceMessageId, onPlayPauseClick = callbacks.onVoicePlayPauseClick, onSeek = callbacks.onVoiceSeek, onSpeedClick = callbacks.onVoiceSpeedClick diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt index bc57a4a163e..c4b8bc2bfdc 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/ChatView.kt @@ -94,7 +94,6 @@ data class ChatViewState( val chatItems: List, val isOneToOneConversation: Boolean, val conversationThreadId: Long? = null, - val currentlyPlayingVoiceMessageId: Int? = null, val hasChatPermission: Boolean = true, val initialUnreadCount: Int = 0, val initialShowUnreadPopup: Boolean = false, @@ -409,7 +408,6 @@ fun ChatView( isSelected = state.highlightedMessageId == chatItem.uiMessage.id, highlightSearchTerm = state.highlightedSearchTerm, context = ChatMessageContext( - currentlyPlayingVoiceMessageId = state.currentlyPlayingVoiceMessageId, isOneToOneConversation = state.isOneToOneConversation, conversationThreadId = state.conversationThreadId, hasChatPermission = state.hasChatPermission, diff --git a/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt index e25220c2ae6..7f40a8a0301 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/chat/VoiceMessage.kt @@ -54,7 +54,6 @@ fun VoiceMessage( message: ChatMessageUi, isOneToOneConversation: Boolean = false, conversationThreadId: Long? = null, - currentlyPlayingVoiceMessageId: Int? = null, onPlayPauseClick: (Int) -> Unit = {}, onSeek: (messageId: Int, progress: Int) -> Unit = { _, _ -> }, onSpeedClick: (messageId: Int) -> Unit = {} @@ -69,7 +68,7 @@ fun VoiceMessage( remember(inversePrimaryColor) { inversePrimaryColor.toArgb() } val onPrimaryContainerColor = colorScheme.onPrimaryContainer remember(onPrimaryContainerColor) { onPrimaryContainerColor.toArgb() } - val remainingSeconds = (typeContent.durationSeconds - typeContent.playedSeconds).coerceAtLeast(0) + val remainingSeconds = (typeContent.durationSeconds - typeContent.playedSeconds) val waveformData = remember(typeContent.waveform) { val floatArr = typeContent.waveform.toFloatArray() if (floatArr.size < WAVEFORM_SIZE) { @@ -84,11 +83,7 @@ fun VoiceMessage( label = "size" ) - val icon = if (message.id != currentlyPlayingVoiceMessageId) { - Icons.Filled.PlayArrow - } else { - if (typeContent.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow - } + val icon = if (typeContent.isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow Column { Row( @@ -123,7 +118,8 @@ fun VoiceMessage( .height(animValue.dp) .fillMaxWidth() .padding(8.dp), // or weight(1f), - waveformData + waveformData, + enabled = true ) TextButton( From 1bae5fdd7ae2b4e0d9d5e2f9ef934766b47f10fe Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Tue, 11 Aug 2026 11:32:25 -0500 Subject: [PATCH 2/2] Implementing suggestions Signed-off-by: rapterjet2004 --- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 6 ++---- .../com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt | 6 ++++++ 2 files changed, 8 insertions(+), 4 deletions(-) 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 92c8f6a6e71..80e68034e27 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -107,8 +107,8 @@ import com.nextcloud.talk.adapters.messages.CallStartedMessageInterface import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.chat.data.io.VoiceMessageMediaService import com.nextcloud.talk.attachmentpreview.FileAttachmentPreviewFragment +import com.nextcloud.talk.chat.data.io.VoiceMessageMediaService import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.FileParameters import com.nextcloud.talk.chat.ui.ChatEmptyState @@ -179,9 +179,9 @@ import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.AudioUtils import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability +import com.nextcloud.talk.utils.CapabilitiesUtil.retentionOfClassifiedRoom import com.nextcloud.talk.utils.CapabilitiesUtil.retentionOfEventRooms import com.nextcloud.talk.utils.CapabilitiesUtil.retentionOfInstantMeetingRoom -import com.nextcloud.talk.utils.CapabilitiesUtil.retentionOfClassifiedRoom import com.nextcloud.talk.utils.CapabilitiesUtil.retentionOfSIPRoom import com.nextcloud.talk.utils.ContactUtils import com.nextcloud.talk.utils.ConversationUtils @@ -876,8 +876,6 @@ class ChatActivity : LocalMessageUtils provides messageUtils, LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) } ) { - val currentlyPlayingId by chatViewModel.currentlyPlayedMessageId.collectAsState(null) - val isOneToOneConversation by remember { mutableStateOf(uiState.isOneToOneConversation) } Log.d(TAG, "isOneToOneConversation=" + isOneToOneConversation) 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 9956571b83b..9bd9189cf83 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 @@ -261,6 +261,12 @@ class ChatViewModel @AssistedInject constructor( val mediaPlayerSeekbarObserver: Flow get() = mediaPlayerManager.mediaPlayerSeekBarPositionMsg + val currentlyPlayedMessageId: Flow = mediaPlayerManager.currentCycledMessage.map { it?.jsonMessageId } + + fun setPlayBack(speed: PlaybackSpeed) { + mediaPlayerManager.setPlayBackSpeed(speed) + } + private val _recordTouchObserver: MutableLiveData = MutableLiveData() val recordTouchObserver: LiveData get() = _recordTouchObserver