diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c6af5483e5..e3b61d4d7a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -195,6 +195,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.2") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4271e3e2e2..38494423c0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,6 +47,7 @@ + @@ -329,6 +330,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 7807525e19..94d796c0d6 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 @@ -99,6 +108,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.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.FileParameters import com.nextcloud.talk.chat.ui.ChatEmptyState @@ -222,6 +232,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 @@ -294,6 +305,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() @@ -396,6 +410,75 @@ 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) * 100f + val secondsPlayed = (currentPosition / 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(150L) + } + } + } + + 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) { @@ -795,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 @@ -817,7 +900,6 @@ class ChatActivity : state = ChatViewState( chatItems = uiState.items, isOneToOneConversation = isOneToOneConversation, - currentlyPlayingVoiceMessageId = currentlyPlayingId, conversationThreadId = conversationThreadId, chatMode = chatMode, highlightedMessageId = uiState.highlightedMessageId, @@ -837,9 +919,21 @@ class ChatActivity : onSwipeReply = { handleSwipeToReply(it) }, onFileClick = { downloadAndOpenFile(it, openWhenDownloadState, downloadingFileState) }, onPollClick = { pollId, pollName -> openPollDialog(pollId, pollName) }, - onVoicePlayPauseClick = { onVoicePlayPauseClickCompose(it) }, - onVoiceSeek = { _, progress -> chatViewModel.seekToMediaPlayer(progress) }, + + // TODO - update these callbacks to reflect the new SOT and move all the + // mediaController logic to viewModel, the goal is to avoid launching coroutines in + // the ChatActivity. I want to clean up the whole syncVoice thing with Player.Listener + 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) }, onOpenThreadClick = { messageId -> openThread(messageId.toLong()) }, @@ -974,6 +1068,123 @@ class ChatActivity : } } + 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 / 1000 + } catch (e: Exception) { + 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) { @@ -1011,57 +1222,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 { @@ -1196,8 +1356,8 @@ class ChatActivity : pendingTargetMessageId = extras?.getString(BundleKeys.KEY_MESSAGE_ID)?.toLongOrNull()?.takeIf { it > 0L } ?: extras?.getLong(BundleKeys.KEY_MESSAGE_ID)?.takeIf { it > 0L } - pendingTargetThreadId = extras?.getString(BundleKeys.KEY_THREAD_ID)?.toLongOrNull()?.takeIf { it > 0L } - ?: extras?.getLong(BundleKeys.KEY_THREAD_ID)?.takeIf { it > 0L } + pendingTargetThreadId = extras?.getString(KEY_THREAD_ID)?.toLongOrNull()?.takeIf { it > 0L } + ?: extras?.getLong(KEY_THREAD_ID)?.takeIf { it > 0L } pendingTargetSearchQuery = extras?.getString(BundleKeys.KEY_SEARCH_QUERY) } @@ -1206,6 +1366,24 @@ 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 the controller connects and is already playing (e.g. background playback), + // start polling immediately + if (mediaController?.isPlaying == true) { + startProgressPolling() + } + + // (Optional) Add a Player.Listener here if you want the Activity + // to reactively listen to playback state changes (e.g., when a track ends). + }, ContextCompat.getMainExecutor(this)) } override fun onSaveInstanceState(outState: Bundle) { @@ -1218,6 +1396,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) @@ -1872,38 +2056,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 } 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 c708456097..1916c85c2f 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 0000000000..82ec07ebe1 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/VoiceMessageMediaService.kt @@ -0,0 +1,36 @@ +/* + * 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? { + return 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 92fb44d470..0d7ce9863e 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 @@ -854,7 +854,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) { 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 4ea119ad7f..4c65bb7c26 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 @@ -33,7 +34,8 @@ const val OVERLAP = 0.025 @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 +43,7 @@ fun ComposeWaveformSeekBar(value: Float, onValueChange: (Float) -> Unit, modifie Slider( value = value, + enabled = enabled, onValueChange = onValueChange, track = { Box( @@ -48,14 +51,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 +93,13 @@ fun Preview() { val waveData = remember { FloatArray(WAVEFORM_SIZE) { (Math.random() % 1).toFloat() } } ComposeWaveformSeekBar( - 0.0f, + 0.97f, {}, 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 f439969e9d..3672ca8a8b 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 bc57a4a163..c4b8bc2bfd 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 e25220c2ae..16f6b72db4 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(