diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 3c78c59..85b1f95 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -455,3 +455,8 @@ - Added tablet-aware max-width layout constraints across major screens (login, verify/reset auth, chats list, chat, profile, settings). - Kept phone layout unchanged while centering content and limiting line width on larger displays. - Fixed voice hold-to-send gesture reliability by removing pointer-input restarts during active recording, so release consistently triggers send path. + +### Step 73 - Voice message send/playback bugfixes +- Fixed voice media type mapping in message repository: recorded files with `voice_*.m4a` are now sent as message type `voice` (not generic `audio`). +- Fixed audio replay behavior: when playback reaches the end, next play restarts from `0:00`. +- Improved duration display in audio/voice player by adding metadata fallback when `MediaPlayer` duration is not immediately available. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index 9d1ced5..f3fc6d0 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -323,7 +323,7 @@ class NetworkMessageRepository @Inject constructor( caption: String?, replyToMessageId: Long?, ): AppResult = withContext(ioDispatcher) { - val messageType = mapMimeToMessageType(mimeType) + val messageType = mapMimeToMessageType(mimeType = mimeType, fileName = fileName) val tempId = -System.currentTimeMillis() val tempMessage = MessageEntity( id = tempId, @@ -495,10 +495,14 @@ class NetworkMessageRepository @Inject constructor( } } - private fun mapMimeToMessageType(mimeType: String): String { + private fun mapMimeToMessageType( + mimeType: String, + fileName: String, + ): String { return when { mimeType.startsWith("image/") -> "image" mimeType.startsWith("video/") -> "video" + mimeType.startsWith("audio/") && fileName.startsWith("voice_", ignoreCase = true) -> "voice" mimeType.startsWith("audio/") -> "audio" else -> "file" } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 946e314..60502eb 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import android.media.AudioAttributes +import android.media.MediaMetadataRetriever import android.media.MediaPlayer import android.net.Uri import android.provider.OpenableColumns @@ -1220,9 +1221,10 @@ private fun AudioAttachmentPlayer( isPrepared = true durationMs = player.duration.coerceAtLeast(0) } - setOnCompletionListener { + setOnCompletionListener { player -> isPlaying = false - positionMs = durationMs + runCatching { player.seekTo(0) } + positionMs = 0 AppAudioFocusCoordinator.release("player:$url") } setDataSource(url) @@ -1245,6 +1247,13 @@ private fun AudioAttachmentPlayer( delay(250) } } + LaunchedEffect(url, isPrepared) { + if (durationMs > 0) return@LaunchedEffect + val fallbackDuration = resolveRemoteAudioDurationMs(url) + if (fallbackDuration != null && fallbackDuration > 0) { + durationMs = fallbackDuration + } + } DisposableEffect(mediaPlayer) { onDispose { runCatching { @@ -1273,6 +1282,10 @@ private fun AudioAttachmentPlayer( isPlaying = false AppAudioFocusCoordinator.release("player:$url") } else { + if (durationMs > 0 && positionMs >= durationMs - 200) { + runCatching { mediaPlayer.seekTo(0) } + positionMs = 0 + } runCatching { mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex]) } @@ -1322,7 +1335,10 @@ private fun AudioAttachmentPlayer( horizontalArrangement = Arrangement.SpaceBetween, ) { Text(text = formatDuration(positionMs), style = MaterialTheme.typography.labelSmall) - Text(text = formatDuration(durationMs), style = MaterialTheme.typography.labelSmall) + Text( + text = if (durationMs > 0) formatDuration(durationMs) else "--:--", + style = MaterialTheme.typography.labelSmall, + ) } } } @@ -1419,6 +1435,19 @@ private fun formatDuration(ms: Int): String { return "$min:${sec.toString().padStart(2, '0')}" } +private fun resolveRemoteAudioDurationMs(url: String): Int? { + return runCatching { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(url, emptyMap()) + val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + duration?.toIntOrNull() + } finally { + retriever.release() + } + }.getOrNull() +} + private fun extractFileName(url: String): String { return runCatching { val path = java.net.URI(url).path.orEmpty()