From bcdc8effe526b06891b01ec51ad3599d1a06bd05 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:22:51 +0000 Subject: [PATCH 1/2] fix: jump to quoted message in search and content filter (#6721) * fix: jump to quoted message in search and content filter When tapping a quoted message during search or content filter, scrollToItem bailed out silently instead of navigating to the message. Load around the target without search/filter params using openAroundItemId, which closes search/filter and positions at the target. Fixes the overly broad guard added in 219381f9 (#5315). * remove logs --------- Co-authored-by: Evgeny Poberezkin --- .../chat/simplex/common/views/chat/ChatView.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 25f769f4b2..f5c07f2742 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -813,7 +813,6 @@ fun ChatView( fun updateAvailableContent(chatRh: Long?, activeChat: State, availableContent: MutableState>) { withBGApi { - Log.e(TAG, "updateAvailableContent") val chatInfo = activeChat.value?.chatInfo if (chatInfo == null) return@withBGApi val types = chatModel.controller.apiGetChatContentTypes(chatRh, chatInfo.chatType, chatInfo.apiId, null) @@ -822,7 +821,6 @@ fun updateAvailableContent(chatRh: Long?, activeChat: State, availableCon availableContent.value = ContentFilter.entries } else { val typeSet: Set = types.union(ContentFilter.alwaysShow) - Log.e(TAG, "updateAvailableContent $typeSet") availableContent.value = ContentFilter.entries.filter { it -> typeSet.contains(it.contentTag) } } } @@ -1826,7 +1824,7 @@ fun BoxScope.ChatItemsList( val chatInfoUpdated = rememberUpdatedState(chatInfo) val scope = rememberCoroutineScope() val scrollToItem: (Long) -> Unit = remember { - scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) + scrollToItem(chatsCtx, remoteHostIdUpdated, searchValue, contentFilter, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, scrollToItemId) } if (chatsCtx.secondaryContextFilter == null) { @@ -2971,7 +2969,10 @@ private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State, de } private fun scrollToItem( + chatsCtx: ChatModel.ChatsContext, + remoteHostId: State, searchValue: State, + contentFilter: State, loadingMoreItems: MutableState, animatedScrollingInProgress: MutableState, highlightedItems: MutableState>, @@ -2986,8 +2987,13 @@ private fun scrollToItem( withApi { try { var index = mergedItems.value.indexInParentItems[itemId] ?: -1 - // Don't try to load messages while in search - if (index == -1 && searchValue.value.isNotBlank()) return@withApi + if (index == -1 && (searchValue.value.isNotBlank() || contentFilter.value != null)) { + val ci = chatInfo.value + apiLoadMessages(chatsCtx, remoteHostId.value, ci.chatType, ci.apiId, + ChatPagination.Around(itemId, ChatPagination.PRELOAD_COUNT * 2), + openAroundItemId = itemId) + return@withApi + } // setting it to 'loading' even if the item is loaded because in rare cases when the resulting item is near the top, scrolling to // it will trigger loading more items and will scroll to incorrect position (because of trimming) loadingMoreItems.value = true From 4545fdd0a9deea17fa0c4b3680f8d1c80621b282 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Fri, 3 Apr 2026 21:24:08 +0100 Subject: [PATCH 2/2] desktop: support sending voice messages, use shared VLC media-player factory (#6739) * desktop: support sending voice messages * alert for unsupported platforms * dont record on error * better initialization * desktop: use shared VLC media-player factory (#6741) * desktop: use shared VLC media-player factory * fix factory --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- .../simplex/common/views/chat/SendMsgView.kt | 27 ++++--- .../commonMain/resources/MR/base/strings.xml | 1 + .../common/platform/RecAndPlay.desktop.kt | 70 +++++++++++++++++-- .../common/platform/VideoPlayer.desktop.kt | 9 +-- apps/multiplatform/desktop/build.gradle.kts | 6 ++ plans/2026-04-02-desktop-voice-recording.md | 39 +++++++++++ 6 files changed, 127 insertions(+), 25 deletions(-) create mode 100644 plans/2026-04-02-desktop-voice-recording.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 4de0175457..9184071c07 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -24,7 +24,6 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatItem import chat.simplex.common.platform.* -import chat.simplex.common.views.usersettings.showInDevelopingAlert import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource @@ -313,21 +312,19 @@ private fun RecordVoiceView(recState: MutableState, stopRecOnNex LockToCurrentOrientationUntilDispose() StopRecordButton(stopRecordingAndAddAudio) } else { - val startRecording: () -> Unit = out@ { - if (appPlatform.isDesktop) { - return@out showInDevelopingAlert() + val startRecording: () -> Unit = { + val filePath = rec.start { progress: Int?, finished: Boolean -> + val state = recState.value + if (state is RecordingState.Started && progress != null) { + recState.value = if (!finished) + RecordingState.Started(state.filePath, progress) + else + RecordingState.Finished(state.filePath, progress) + } + } + if (filePath.isNotEmpty()) { + recState.value = RecordingState.Started(filePath = filePath) } - recState.value = RecordingState.Started( - filePath = rec.start { progress: Int?, finished: Boolean -> - val state = recState.value - if (state is RecordingState.Started && progress != null) { - recState.value = if (!finished) - RecordingState.Started(state.filePath, progress) - else - RecordingState.Finished(state.filePath, progress) - } - }, - ) } val interactionSource = interactionSourceWithTapDetection( onPress = { if (recState.value is RecordingState.NotStarted) startRecording() }, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 08d4adea70..95c3311a3d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -54,6 +54,7 @@ %d messages blocked by admin sending files is not supported yet receiving files is not supported yet + Voice recording is not supported on your platform you unknown message format invalid message format diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 9f34891b37..59d71a83f1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -5,6 +5,7 @@ import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* +import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.State import uk.co.caprica.vlcj.player.component.AudioPlayerComponent @@ -12,20 +13,77 @@ import java.io.File import java.util.* import kotlin.math.max +internal val vlcFactory: MediaPlayerFactory by lazy { MediaPlayerFactory() } + actual class RecorderNative: RecorderInterface { + private var player: MediaPlayer? = null + private var progressJob: Job? = null + private var filePath: String? = null + private var recStartedAt: Long? = null + override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { - /*LALAL*/ - return "" + VideoPlayerHolder.stopAll() + AudioPlayer.stop() + val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_", tmpDir), ".tmp", tmpDir) + fileToSave.deleteOnExit() + val path = fileToSave.absolutePath + filePath = path + val mrl = when { + desktopPlatform.isMac() -> "qtsound://" + desktopPlatform.isLinux() -> "pulse://" + desktopPlatform.isWindows() -> "dshow://" + else -> { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.voice_recording_not_supported)) + return "" + } + } + val sout = ":sout=#transcode{vcodec=none,acodec=mp4a,ab=32,channels=1,samplerate=16000}:std{access=file,mux=mp4,dst=$path}" + val options = mutableListOf(sout, ":sout-avcodec-strict=-2") + if (desktopPlatform.isWindows()) { + options.add(":dshow-vdev=none") + options.add(":dshow-adev=") + } + RecorderInterface.stopRecording = { stop() } + progressJob = CoroutineScope(Dispatchers.Default).launch { + // Shared factory init may take a few seconds on first VLC use — progress shows 0 until recording starts + val p = vlcFactory.mediaPlayers().newMediaPlayer() + player = p + p.media().play(mrl, *options.toTypedArray()) + recStartedAt = System.currentTimeMillis() + while (isActive) { + val ms = progress() + onProgressUpdate(ms, false) + if (ms != null && ms >= MAX_VOICE_MILLIS_FOR_SENDING) { + stop() + break + } + delay(50) + } + }.apply { + invokeOnCompletion { onProgressUpdate(realDuration(path), true) } + } + return path } override fun stop(): Int { - /*LALAL*/ - return 0 + val path = filePath ?: return 0 + RecorderInterface.stopRecording = null + runCatching { player?.controls()?.stop() } + runCatching { player?.release() } + runBlocking { progressJob?.cancelAndJoin() } + progressJob = null + filePath = null + player = null + return (realDuration(path) ?: 0).also { recStartedAt = null } } + + private fun progress(): Int? = recStartedAt?.let { (System.currentTimeMillis() - it).toInt() } + + private fun realDuration(path: String): Int? = AudioPlayer.duration(path) ?: progress() } actual object AudioPlayer: AudioPlayerInterface { - private val player by lazy { AudioPlayerComponent().mediaPlayer() } + private val player by lazy { AudioPlayerComponent(vlcFactory).mediaPlayer() } override val currentlyPlaying: MutableState = mutableStateOf(null) private var progressJob: Job? = null @@ -170,7 +228,7 @@ actual object AudioPlayer: AudioPlayerInterface { override fun duration(unencryptedFilePath: String): Int? { var res: Int? = null try { - val helperPlayer = AudioPlayerComponent().mediaPlayer() + val helperPlayer = AudioPlayerComponent(vlcFactory).mediaPlayer() helperPlayer.media().startPaused(unencryptedFilePath) res = helperPlayer.duration helperPlayer.stop() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index 50eeaee604..c5a38ec4a1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -10,6 +10,7 @@ import uk.co.caprica.vlcj.media.VideoOrientation import uk.co.caprica.vlcj.player.base.* import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent +import uk.co.caprica.vlcj.player.component.MediaPlayerSpecs import java.awt.Component import java.awt.image.BufferedImage import java.io.File @@ -32,7 +33,7 @@ actual class VideoPlayer actual constructor( override val duration: MutableState = mutableStateOf(defaultDuration) override val preview: MutableState = mutableStateOf(defaultPreview) - val mediaPlayerComponent by lazy { runBlocking(playerThread.asCoroutineDispatcher()) { getOrCreatePlayer() } } + val mediaPlayerComponent by lazy { getOrCreatePlayer() } val player by lazy { mediaPlayerComponent.mediaPlayer() } init { @@ -207,9 +208,9 @@ actual class VideoPlayer actual constructor( private fun initializeMediaPlayerComponent(): Component { return if (desktopPlatform.isMac()) { - CallbackMediaPlayerComponent() + CallbackMediaPlayerComponent(MediaPlayerSpecs.callbackMediaPlayerSpec().apply { withFactory(vlcFactory) }) } else { - EmbeddedMediaPlayerComponent() + EmbeddedMediaPlayerComponent(MediaPlayerSpecs.embeddedMediaPlayerSpec().apply { withFactory(vlcFactory) }) } } @@ -277,7 +278,7 @@ actual class VideoPlayer actual constructor( private fun putPlayer(player: Component) = playersPool.add(player) - private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent() + private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent(MediaPlayerSpecs.callbackMediaPlayerSpec().apply { withFactory(vlcFactory) }) private fun putHelperPlayer(player: CallbackMediaPlayerComponent) = helperPlayersPool.add(player) } } diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 1e7bda37c4..8f072539e8 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -73,6 +73,12 @@ compose { iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.icns")) appCategory = "public.app-category.social-networking" bundleID = "chat.simplex.app" + infoPlist { + extraKeysRawXml = """ + NSMicrophoneUsageDescription + SimpleX needs microphone access to record voice messages + """ + } val identity = rootProject.extra["desktop.mac.signing.identity"] as String? val keychain = rootProject.extra["desktop.mac.signing.keychain"] as String? val appleId = rootProject.extra["desktop.mac.notarization.apple_id"] as String? diff --git a/plans/2026-04-02-desktop-voice-recording.md b/plans/2026-04-02-desktop-voice-recording.md new file mode 100644 index 0000000000..e29af72f34 --- /dev/null +++ b/plans/2026-04-02-desktop-voice-recording.md @@ -0,0 +1,39 @@ +# Desktop Voice Recording + +## Overview + +Implement voice recording on desktop using vlcj (already a dependency). The `RecorderNative` class is currently a stub. All UI is already in common code. + +## Files to modify + +1. `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt` — implement `RecorderNative` +2. `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt` — remove desktop "in development" guard (line 317-318) +3. `apps/multiplatform/desktop/build.gradle.kts` — add `NSMicrophoneUsageDescription` to macOS Info.plist + +## RecorderNative implementation + +Uses `MediaPlayerFactory` + `MediaPlayer` to capture from default microphone and transcode to AAC/m4a via VLC's sout chain. + +Platform-specific capture MRLs: +- macOS: `qtsound://` +- Linux: `pulse://` +- Windows: `dshow://` with `:dshow-vdev=none :dshow-adev=` + +Transcode options: `vcodec=none,acodec=mp4a,ab=32,channels=1,samplerate=16000` — matches Android (mono, 16kHz, 32kbps AAC). + +Factory requires `--sout-avcodec-strict=-2` to enable FFmpeg's native AAC encoder. + +Progress tracked via elapsed time (VLC capture has no position API). Duration read via `AudioPlayer.duration()` after stop. + +Max duration: enforced by stopping recording after `MAX_VOICE_MILLIS_FOR_SENDING` (300,000 ms) in the progress coroutine. + +## macOS permission + +Add `NSMicrophoneUsageDescription` to Info.plist via Gradle `infoPlist` block. + +## What does NOT change + +- `RecorderInterface` (common) +- `ComposeView.kt`, `ComposeVoiceView` — already handle voice preview/sending +- Audio format — `.m4a` (matches Android) +- All voice recording UI — already in common code