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(